Calling code across layers shouldn't require a ton of boilerplate. Crafting complex incantations to "call a function" just because it lives "somewhere else" is — in my opinion — pretty silly. For reasons I don't understand, we have a tendency to tolerate multi-step tutorials that instruct us to write dozens of lines of incantation code to ... uhh ... "call a function". But, some ecosystems already perform magic for you when you make service calls. (I.e., call functions that live "somewhere else.") For example, when you use a SOAP
service in the .NET
ecosystem, you pretty much just import a WSDL
and then call the service as though it were any other local code.
Even back in my PHP days (over a decade ago), I had created a similar layer for myself by emitting the glue directly from my backend — a less feature-full and distinctly more "hacky" solution than .NET's
strongly typed code generation. But, the magic was similar. My build system made the calls effectively transparent. My front-end JavaScript invoked backend PHP methods as thought they were just local functions.
Reflecting on magic like this, it can tempting to think that it's too much magic. Or to think that perhaps it "hides too many details I want control over." There are concerns that warrant hesitation, some cause to consider what knobs you may need to tinker with. I get it. But, here's a gentle reminder (if only to my future self): Function calling magic is a completely essential part of writing completely local code too.
If you don't already see the point, riddle me this! When is the last time you disavowed your language's function calling mechanism in favor of a hand-rolled call stack and a pile of GOTO
s? (Simply using a stack in your code probably doesn't count. In most languages, your stack manipulation depends on the language's builtin function calling mechanism.)
So, when it comes to Web Workers, it can see how it makes sense for the primitives to be a little complicated. What make far less sense is why seemingly intelligent folks like myself tend to operate for so long as though "that's just it's done." How do smart people become so accustomed to repeating the same incantation over and over, hand-casting types on either end of message handlers, continually hoping the contracts don't drift on either end, and so on ...
In our laziness, we tend to do a lot of extra work. And it's generally more error-prone work at that.
Thankfully, I've been working largely in the TypeScript ecosystem over the past few years, and having created similar tricks for service calls using the npm
lifecycle and some exports
trickery, I've finally made some time to tackle Web Workers. I did look briefly at some existing tooling, but nothing immediately spoke to me. Nothing quite at the level of magic I want. So, I'm rolling my own. (Also, sometimes it's just fun to dive in and hand-roll a thing.)
So, let's look at a sample worker function.
The worker just counts to number (provided by the caller) and reports progress to a given callback if given.
// workers/2025-07-06/src/index.ts
import { SingleWorker } from 'wirejs-web-worker';
export const worker = SingleWorker({
async count(
upTo: number,
options?: { tick?: (pct: number) => void }
) {
let lastUpdate = new Date();
options?.tick?.(0);
let c = 0;
for (let i = 0; i < upTo; i++) {
let current = new Date();
if (current.getTime() - lastUpdate.getTime() > 50) {
options?.tick?.(i / upTo);
lastUpdate = current;
}
c++;
}
options?.tick?.(1);
return c;
}
});
It's a little more complicated of a function than is needed to demonstrate the point. But, I want to be clear here that even callback functions can be marshalled across the wire in a sensible way.
From the calling side...
I can import the worker from the worker package as though it were any other object. (More on that in a moment.) And, I can call methods on it like normal methods.
import { worker } from 'worker-2025-07-06';
const start = () => worker.count(123_000_000, {
tick: pct => {
if (pct < 1) {
self.data.output = html`<span>${(pct*100).toFixed(1)}% done ...</span>`;
}
}
}).then(x => self.data.output = html`
<span>Counted to ${x.toLocaleString()}. <button
onclick=${start}>Start over.</button></span>
`);
start();
And the result is this:
...
So, what's the trick?
It really boils down to two basic things. Firstly, we create a subpackage and control the outsider's view of the package via the exports
property. My somewhat naive solution's package.json
tells the IDE and TypeScript to look at the code itself when determining types. (This could also point at a generated d.ts
file. But, as I already said, I'm lazy.) And, it tells the bundler to look at a build artifact.
// package.json
{
"exports": {
"types": "./index.ts",
"default": "./dist/index.js"
}
}
So far, this is actually very normal TypeScript/JavaScript stuff. Almost any time you import code from somewhere else in the TS/JS ecosystem, you're pointing the IDE and TypeScript at one export and the bundler at another. But, that brings us to the second tricky bit.
I'm not just creating a bundle during my build step of this subpackage. Instead, my builder for the Web Worker subpackage does three things:
- Bundles the worker code.
- Injects the bundled worker code into a Web Worker wrapper.
- Bundles the wrapper (Which, as you'll recall, includes the worker bundle.)
So, when the client calls function, it's actually calling the wrapper. The wrapper spins up a Web Worker using the embedded worker bundle and manages the marshalling of messages to and from the worker through a Proxy.
And, here's the best part: You can try my solution out for yourself! (Probably. I didn't actually test it in your environment.) It's wirejs-web-worker
in NPM.
Alternatively, have some fun and roll your own. 🍻
Or not at all. But, if you were waiting for some encouragement to go make something marginally easier for yourself with code — if only for the fun and learning, here it is:
Go make something marginally easier for yourself with code. (If only for the fun and learning.)
If you enjoyed anything about this article, subscribe so you don't miss out!
Published: 7/8/2025.
Topics: javascript, typescript, web workers, web development, npm, neat tricks, productivity, package.json