Sharing Threads in JavaScript
Previously I wrote about the basics of threading in JavaScript. In short, threads are the worker specification, and you can pass messages to workers and receive messages from workers.
In the previous article, we saw how we could spin up a thread and use it to do some job outside of the main event loop. Which is perfect for most use cases!
Except for when it isn’t.
Most users don’t just have one tab open, they have many tabs open. And they may have many tabs open for the same site. This is especially true for sites where there’s a lot of data that users need to compare, correct, and enter. Often users will have two (or more) tabs open side-by-side so they can compare and contrast data, or copy data over.
With our previous example, each tab gets its own background thread - completely isolated from all other tabs. Which means if a user has two tabs open, then they have two additional background threads, and if they have 10 tabs open then they have 10 additional background threads.
The problem compounds once we start having more threads. It turns out, workers can spawn other workers which in turn creates more threads, and those workers can spawn even more. If we have such a structure, then the extra resource consumption can start to cascade.
Of course, modern browsers have some safeguards to control the impact. But, we don’t want to be wasteful developers here, especially if most of our threads are idle a majority of the time (like in my example where I offload long-running user interactions to a background process). Instead, we’d like a way to say “browser, give me up to this many threads for my site and share those threads across tabs.”
Fortunately, we can have threads be shared across tabs simply by using SharedWorker instead of Worker.
Well, almost. Since SharedWorker is shared across tabs, the browser needs to know which tab is sending or receiving data. This is done with ports. Ports simply indicate which thread is doing the communicating. As a result, every postMessage and onmessage must be tied to a port - on both the worker and page side. If we tried to access postMessage or onmessage directly, we would get a “function undefined” error.
So, with that background knowledge done, let’s make a shared worker!
importScripts('calcs.js')
let jobCount = 0
onconnect = (e) => {
const port = e.ports[0]
port.onmessage = e => {
++jobCount
const job = e.data.job
const jobId = e.data.jobId
if (job.hasOwnProperty('factorialOf')) {
return sendResponse(port, jobId, factorial(job.factorialOf))
}
if (job.hasOwnProperty('fibonacciOf')) {
return sendResponse(port, jobId, fibonacci(job.fibonacciOf))
}
sendError(port, jobId, "bad job type")
}
}
function sendResponse(port, jobId, res) {
port.postMessage({jobId, res, jobCount})
}
function sendError(port, jobId, err) {
port.postMessage({jobId, err, jobCount})
}And now let’s use it!
const sharedBackgroundThread = new SharedWorker('shared-background-process.js')
const workQueue = {}
function enqueueSharedJob(job) {
const jobId = crypto.randomUUID ? crypto.randomUUID() : ++incId
const ret = new Promise((resolve, reject) => {
workQueue[jobId] = {
resolve,
reject
}
})
sharedBackgroundThread.port.postMessage({jobId, job})
return ret
}
sharedBackgroundThread.port.onmessage = e => {
const jobId = e.data.jobId
try {
console.log('Shared worker job count', e.data.jobCount)
if (typeof e.data.res !== "undefined") {
workQueue[jobId].resolve(e.data.res)
} else {
workQueue[jobId].reject(e.data.err || 'BAD MESSAGE FORMAT')
}
} finally {
delete workQueue[jobId]
}
}
await enqueueSharedJob({factorial: 5})It has a few extra steps with the ports, but otherwise it’s not that much different than using a normal worker.
One thing you may have noticed is that I didn’t include the code for the actual calculations. Instead, I have an
importScriptscall. TheimportScriptscall will load another JavaScript file into the context of the worker. It’s how we can load libraries and reuse code between workers and pages. In this case, I put the code for the factorial and fibonacci calculations inside a different file which I’m then loading.
There is one other advantage to using a shared worker over a normal worker, and that’s communication across tabs! We can store all the ports, and have events broadcast to every open tabs, or have a cache, or some sort of shared state.
For simplicity, we’re going to just add a cache. The cache is going to be a simple “for this function input, cache this value” type of cache. It won’t have cleanup or upper bounds on size, but it’ll do for a small example.
// calc.js
const cache = {
factorial: {},
fibonacci: {}
}
function fibonacci(n) {
if (typeof cache.fibonacci[n] !== 'undefined') {
return cache.fibonacci[n]
}
let n1 = 0n
let n2 = 1n
for (let i = 0; i < n; ++i) {
[n1, n2] = [n2, n1 + n2]
}
cache.fibonacci[n] = n1
return n1
}
function factorial(n) {
if (!(n instanceof BigInt)) {
n = BigInt(n)
}
let original = n
if (typeof cache.factorial[n] !== 'undefined') {
return cache.factorial[n]
}
let res = 1n
while (n > 1n) {
res *= n
n -= 1n
}
if (!(original instanceof BigInt)) {
cache.factorial[original] = res
}
return res
}The cache will then persist so long as our shared worker does, and we will be able to use any of the calculated values across our tabs.
But, how long does our shared worker last? Well, per the spec it’s basically so long as there’s at least one context referring to that shared worker (page, iframe, window, etc.) with a grace period for page navigation that’s defined by the browser.
For us, it means that any data we store in a shared worker is not durable. For background jobs, that’s totally fine.
One other detail to keep in mind is that there’s a shared origin policy for shared workers. Basically, if a page gets loaded on your site, and another page has already made a shared worker, than the new page will use the existing shared worker - even if the script file has changed since the worker was first made.
This can make live updates more complicated, since it’s not as simple as “just logout and log back in” anymore. It’s “close all the tabs, wait 10 to 30 seconds, then reopen everything.”
That’s not to say there aren’t solutions - they’re just not elegant. We just need to have a version in the script.
// old-page.html
const worker = new SharedWorker('worker-script-v1.0.0.js');
// new-page.html
const worker = new SharedWorker('worker-script-v1.1.0.js');All this to say, shared workers offer some really cool stuff, but due to their shared nature they’re a little trickier to get right.
Of course, we’re not done yet. There’s still more to go when it comes to JavaScript threads. I’ll post more soon.

