Programmer Productivity

A Poor Man's Web Worker

How an experimental technology saved the day

by Joe Honton
This was the catalyst for exploring alternatives to Web Workers. In this example, Concord, New Hampshire needs better collision detection and automatic adjustment of label placement.

For a while now, I've been developing a browser-based Earth mapping system using JavaScript and HTML canvas. One of the key challenges of this work has been keeping the user interface responsive while projecting 3D latitudes/longitudes onto a 2D canvas. Every point of every feature on the map must be transformed using this trigonometry:

x = R cosΦ sin(λ - λ₀)
y = R (cosΦ₀ sinΦ - sinΦ₀ cosΦ cos(λ - λ₀))

To appreciate the scale of this problem, a typical map may comprise dozens of layers, with each layer having hundreds of features, and with each feature comprising thousands of points. All of these points must be run through that projection formula any time the user pans or zooms. Furthermore, because the user can freely reposition the point of observation, no meaningful caching scheme can be employed.

I always thought that there would come a time when shifting that math to a separate thread would be necessary, so I've kept my eye on the promise of web workers.

Recently, I've been implementing a collision detection system to recognize and correct overlapping map labels. The computations necessary to do this go way beyond what's acceptable to the user. This was the project's tipping point — it was time to look seriously at implementing a web worker.

Sadly, the results of that effort were disappointing.

The mechanics of web workers are simple enough. Any of the online tutorials are sufficient to get us started. Just to review, the promise of web workers is that the browser's main thread can be kept free to respond to mouse and keyboard events, while the web worker thread can be fully dedicated to computations.

Unfortunately, there's a major drawback with the way this works: the postMessage function must be used to shuttle inputs and outputs back and forth. The toy examples that are demonstrated in tutorials often brush this off as a simple implementation detail, when in practice it becomes a major bottleneck.

In Optimizing Performance with Web Workers, Yi Chen documented this problem, noting "serialization/deserialization of 4000 objects took around 20 ~ 30ms and canceled out the benefit of porting a ~100ms process to workers."

James Milner looked at this in even finer detail in Examining Web Worker Performance, concluding that "the real cost of Web Workers comes from the transfer of a data from the main thread to the Web Worker and the return of data to the main thread."

In my case, transferring 100K pairs of {Φ, λ} and {x, y} between the main thread and the web worker was an untenable design.

The advertised way around this problem is to use transferable arrays. These are large buffers stuffed with numbers by the main thread, processed by the worker, then shuttled back and unwrapped by the caller. An extension of this approach, which has been promised for a long time, is the OffscreenCanvas function. Chrome and Opera delivered an implementation of this in 2018, but Firefox and Safari still do not support it (caniuse).

While researching what to do, I stumbled across another experimental technology which I'll call the poor man's web worker, but which is more properly called requestIdleCallback. Safari has formally placed it under consideration, while every other browser already provides an implementation. In the mean time, a 62-line polyfill can temporarily serve our needs.

The concept for requestIdleCallback is straightforward. The browser knows when its queue is empty and how much time remains until the start of its next "event loop". We can ask the browser to notify us when that occurs. The browser does this through the callback function that we register with our request. It also tells us how much free time is available for us to use.

When using requestIdleCallback our responsibility is to keep an eye on the clock while working through our number crunching, and to yield control back to the browser when the clock has run out. If we've yielded before completion, we need to capture enough state information to resume our work where we left off, and ask the browser to notify us when another slice of time becomes available.

Here's the scaffolding I've used, which can be adapted for similar problems:

// A large array of points awaiting projection
var queue = [];

// index into the array of the next item to compute
var lastIndex = 0;

var idleCallbackId = 0;

. . .

if (queue.length > 0 && idleCallbackId == 0)
idleCallbackId = requestIdleCallback(computeLayout);

And here's the poor man's web worker:

function computeLayout(idleDeadline) {
var milliseconds = idleDeadline.timeRemaining();
var haltTime = + milliseconds;

for (let i=lastIndex; i < queue.length; i++) {
if ( > haltTime)

// pseudocode
x = R cosΦ sin(λ - λ₀)
y = R (cosΦ₀ sinΦ - sinΦ₀ cosΦ cos(λ - λ₀))

lastIndex = i;

// if not complete, ask for another notification
if (lastIndex < queue.length-1)
idleCallbackId = requestIdleCallback(computeLayout);
idleCallbackId = 0;

For crisp screen rendering and smooth movement of the mouse at a 60 hertz refresh rate, the browser will need to take care of new business every 16.67ms (1000ms / 60 = 16.67ms). After it has taken care of pending DOM events and memory management overhead, any remaining time is metered out to the registered requestIdleCallback.

The idea that real progress could be made with time slices in the 1ms ― 15ms range was not at all obvious. In practice though, this has worked out pleasingly well.

One final note. Careful readers will notice that the call to requestIdleCallback is embedded within the computeLayout callback function itself. This looks like a recursive call, and indeed, while debugging your code with Chrome's inspector, you'll notice the stack appears to be growing with each new request. Don't be misled — this is not really recursion. Just like it's cousin, requestAnimationFrame, the browser is doing some under-the-covers tomfoolery. (It's apropos that the answer to the question about the stack overflowing is itself hosted on stack overflow!)


  • Web workers has been much hyped as a silver bullet, but often falls short of its promise.
  • Shuttling data between the main thread and the worker using postMessage is a major bottleneck.
  • The requestIdleCallback function can successfully be used as an alternative to web workers.

A Poor Man's Web Worker