Crux Read real worker, postMessage, and service-worker snippets — predict the runtime behaviour or the bug, then pick the highest-leverage fix a senior engineer makes first.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
Worker bugs hide in the message boundary and the lifecycle handler, not in the algorithm. Read each snippet, predict what actually happens at the boundary, and choose the fix before reaching for a profiler.
Goal
Practise the loop you run in every worker incident: read the postMessage call or the lifecycle handler, find the hidden clone tax or the detached-buffer or the missing waitUntil, and reach for the highest-leverage fix.
Snippet 1 — the clone that did not move
// main.js — offloading a heavy image filter to a workerconst worker = new Worker('filter.js', { type: 'module' });const pixels = new Uint8ClampedArray(width * height * 4); // ~32 MBworker.postMessage({ pixels, width, height }); // (A)applyOtherUiWork(); // (B) runs ~32 ms late
Quiz
Completed
Line (B) runs about 32 ms later than expected even though the filter runs in the worker. What is happening at line (A) and what is the one-line fix?
Heads-up Worker startup happens off the calling thread and does not block line (B). The block is the synchronous structured clone of the 32 MB buffer on the main thread.
Heads-up Line (B) does not await anything — it runs right after postMessage returns. The delay is the synchronous clone inside postMessage, not the worker's reply.
Heads-up It is entirely avoidable. Pass the buffer in a transfer list and the copy becomes an O(1) ownership handoff; the ~32 ms disappears.
Snippet 2 — the buffer used after transfer
// main.jsconst buf = new Float32Array(1_000_000);fillSamples(buf);worker.postMessage(buf, [buf.buffer]); // transferredconst sum = buf.reduce((a, x) => a + x); // (C) reads buf again
Quiz
Completed
What happens at line (C), and what does it tell you about transfer semantics?
Heads-up Transfer is the opposite of copy. It hands the underlying memory to the receiver and detaches the sender's view. There is no second usable copy on the sender.
Heads-up There is no shared memory here — transfer is a one-way ownership move, not a live shared view. The sender's buffer is simply detached and empty.
Heads-up reduce runs synchronously on the calling thread. The point is that the buffer it would read is detached, not relocated computation.
Snippet 3 — the install handler that loses its cache
Under load this service worker sometimes activates with an empty or half-populated cache. What is the defect and the fix?
Heads-up addAll rejects as a unit if any request fails; partial population here comes from the unawaited promise, not addAll. Wrapping it in event.waitUntil keeps the worker alive until it settles.
Heads-up Version-tagged names are exactly how you avoid collisions, and nothing deletes shell-v3 here. The bug is the missing waitUntil letting the worker advance early.
Heads-up Absolute same-origin paths cache fine. The non-determinism is the unawaited promise, fixed by event.waitUntil.
Snippet 4 — the OffscreenCanvas handoff
// main.jsconst canvas = document.querySelector('#viz');const offscreen = canvas.transferControlToOffscreen();worker.postMessage({ offscreen }, [offscreen]);// later, on the main thread:const ctx = canvas.getContext('2d'); // (D)ctx.fillRect(0, 0, 100, 100); // (E)
Quiz
Completed
The worker renders fine, but lines (D)–(E) on the main thread misbehave. What happens and why?
Heads-up The platform forbids two threads racing on one bitmap. transferControlToOffscreen makes ownership exclusive — the main thread can no longer draw into that canvas.
Heads-up There is no release-and-reclaim mechanism. Transfer is permanent for the canvas's lifetime; the main-thread context is simply unavailable.
Heads-up After transferControlToOffscreen the element has no own rendering context to hand out — the worker owns the bitmap. You would need a separate canvas element.
Recap
Every worker incident is read at the boundary, not in the algorithm: a by-value postMessage of a large buffer blocks the sender on a synchronous structured clone, so transfer the ArrayBuffer; a transferred buffer is detached on the sender, so never read it afterward (use SharedArrayBuffer if you must share); a service-worker install must wrap async cache population in event.waitUntil or the worker advances or dies early; and transferControlToOffscreen makes canvas ownership exclusive, so the main thread can no longer draw. Read the message and lifecycle handlers first — that is where the cost and the bugs live.