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