Concurrency With Workers

Published 2026-03-08, About 12 minute read.

Javascript is single threaded... mostly! In my last post I traced the beginnings of how the microtask queue evolved in javascript, and how this can lead to asynchronous code. We found that asynchronous code is not truly concurrent, because the single thread in javascript can only handle one task or microtask at a time. Asynchronicity is all about arranging the tasks or microtasks in a way to handle promises and future values that resembles concurrency.

If you want true concurrency, you need to work with workers!

Workers are threads that can get spun up in separate processor cores, which means you can actually run code concurrently!

Let's talk about how you run code in a worker, and how you communicate between threads, and how you might kick off a thread of work.

Kicking off a worker 📎

It's actually quite easy to kick off a worker with a simple round trip of messaging.

This particular worker is called a dedicated worker. They're dedicated to this script which spun it up and only this context can communicate with the script. Other contexts (windows, tabs, iframes) can't access this worker- we can't share it.

There is another kind of worker called a SharedWorker. These workers can be communicated with by other contexts, but they communicate a little differently. Sharing this worker means that any script or context that wants to use this worker must be from the same origin. A little more red tape, and a little different to work with.

Let's talk about the URL.createObjectURL, though. This is a method that allows us to create a blob url so we can actually take in-memory scripts and store them as a proper URL. That's how we can get away with writing a script as a string and the worker can take that string, transformed into a url blob.

This is fine for us, because we're just working within this one document and we're not worried with sharing workers amongst other workers, iframes, or other contexts.

Breaking up the work 📎

Using workers is really nice when we have a task that can be broken up into pieces that can be run in separate threads with no knowledge of each other or communication. It's nice because the concurrent work doesn't have to coordinate or communicate between the different threads of calculations. They can work completely in isolation in their threads.

I'm going to demonstrate a delightfully parallel algorithm that is near and dear to my former-math-teacher heart: determining if a number is a prime. We'll call it isPrime.

This algorithm is not going to be efficient on purpose. It's going to take a number and start dividing by 2 and then every odd number up to half of the number to check if it's divisible by any number. If it's not, it will return true. If a number does divide evenly into our candidate, it will return false

We're doing this in a very brute force way to demonstrate an algorithm that can be divided up into nice parallel threads. If, say, we were checking to see if 10,007 is prime (it is!) then we would have a list of numbers to try to divide into 10,007 that is sqrt(10,007) rounded down, which is 100.

There's a little snag here. We're using BigInt. Turns out, this allows us to work with numbers that are huge but the standard Math library in javascript doesn't work with BigInt. 😩 So to make it a little easier, we're checking numbers all the way up to half of the candidate. That's okay, the point is that we see how threads reduce our work.

So in this case, we would divide those 2500 or so odd numbers into groups. Each worker thread could then work on a group of those numbers. If a thread ever returns back saying "I found a factor," we can cancel all work- it's not prime. But if all threads come back saying there were no divisors, you know you have a prime!

Let's try this out. I'll also use console.time so we can see how long this takes

That was quick- about 9 milliseconds on my computer, because even though this is prime and we have to check every number up to basically 5000, the checking goes relatively fast.

And most numbers aren't prime, so if you randomly check a big number (like 1234567890987654321) it might finish in much less time because you might run into a divisor and the algorithm would exit early. For 1234567890987654321n, it takes my computer 9 milliseconds because that huge number is divisible by 3.

This gets into worst case complexity. In our case, we just want to test the time on prime numbers mostly, because prime numbers are the "worst case," i.e. for prime numbers we have to check every odd number up to half of the number.

So try this: in the example above, test 10_000_000_019n. On my computer it took 12 seconds to finish the calculation. This is because it's prime, and it shows that even relatively small numbers take some time to calculate, especially in the worst case.

Okay, let's do this. We'll test 10_000_000_019n, but we'll make a "spawn" function that spawns a worker that works on a range of numbers, so we're breaking up the work. The spawn function will take a candidate, a range of numbers to check divisibility into the candidate, and then a callback that will pass back the result for that range.

Hopefully you can see where I'm going with this. We want to now split up the work into "bands" and run a worker for each band. This is where Navigator.hardwareConcurrency comes in. We will divide the range into however many logical threads the browser API tells us we can use, and spawn a worker for each range.

That is much faster! But there's an issue that you're probably noticing here. We have different threads reporting back truly independently of each other. What if a thread finds that there is a factor that divides our candidate? Well, that thread finishes quickly and the other threads continue. The other threads report back their individual results, but there's no final answer. The user needs to see the different results and identify what the real result is.

Try the above example with 10_000_000_020n and you'll see that there are a few ranges that will return relatively quickly with divisors, but there are a few ranges yet that will come back "prime."

It's not too hard to aggregate the answer, it's just not that pleasing!

The proper way to return results and coordinate threads 📎

We can do better. We can leverage promises and use that abstraction and its APIs to cleanly aggregate results from each of our bands.

Instead of using a callback, let's have the spawning function return a promise.

Much cleaner! We can await all of the threads with Promise.all, and then we just check to see if every result for each band is prime. Check out the results of the above example if you use 10_000_000_020n.

That's all for now! 📎

Workers are fun. They just have the unfortunate ceremony to handle them.

If you want to see another application of this, you can check out my Typescript implementation of Ray tracing in a weekend. I ported the entirety of the project over from C code in the excellent tutorial. I then asked Claude to move the embarrassingly parallel functionality of rendering a single pixel into a web worker. The web worker has the responsibility of working through bands of rows of the picture. It cut down rendering time tremendously! You can see it in action here. (And there's a GPU implementation branch if you're further interested. This also was mostly accomplished through Claude porting my project.)

For further exploration of how to make workers fun, there's a very interesting library that lets you create and interact with workers as if they are on the main thread called Comlink. It's a very interesting use of proxies to "fake" calling methods on just another class instance. Under the hood, messages and arguments are being posted to the class inside of a web worker.

Until next time! Find me on Bluesky or Mastodon. I also have an RSS feed here

⬅️ Previous Post
Back to top