skip to content

Workers, Service Workers, and Offline

You will learn how to get work off the main thread and how a service worker turns a site into an offline-capable, installable app. Interviewers ask because both answer the same recurring question: what do you do when one thread is not enough?

on this pageshow

explore

questions

page 1 of 2

In a browser, a page calls worker.postMessage(payload) on a dedicated Web Worker and then immediately mutates payload. Does the worker see the mutation? Explain what the worker actually receives and how it reads it.

level: juniorimportance: must knowfreq 52%

answer

  1. two threads, no shared objects
  2. the value is reconstructed, not referenced
  3. snapshot taken when you call it
  4. later edits never cross
  5. handler gets an event, not the payload

basics

~20 s

No. postMessage sends a copy made at the moment of the call, so later edits to the original never reach the worker. The worker gets a message event asynchronously and reads the copy from event.data; the two threads share no objects.

solid answer

~50 s

`postMessage` is not a shared reference — it runs the structured clone algorithm on the value **synchronously, at the call**, and queues the resulting copy for delivery. So mutating `payload` on the next line changes only the page's object; the worker still receives what the value looked like when `postMessage` returned. On the worker side the copy arrives as a `MessageEvent` on the global scope, and you read it with `self.onmessage = (event) => { ... event.data ... }` or `self.addEventListener('message', ...)`; replying is the mirror image, `self.postMessage(result)`, read on the page as `event.data`. Delivery is asynchronous: the message becomes a task on the receiver's event loop, so nothing has happened at the instant `postMessage` returns. The practical consequence is that there is no shared mutable state between page and worker over this channel — every exchange is a copy in one direction, and whichever side owns a piece of state has to keep owning it.

code

javascript · 9 lines
javascript
const worker = new Worker('/echo.worker.js');
const payload = { count: 1, items: [] };

worker.addEventListener('message', (event) => {
  console.log(event.data.count); // 1, not 2
});

worker.postMessage(payload);
payload.count = 2; // too late: the copy was taken above

go deeper

for a junior

Be ready to say plainly that postMessage copies the value: the worker receives its own object, and edits made after the call never cross. Remember the payload is on event.data, not the handler argument itself.

for a middle

Explain that the copy is produced synchronously at the call and delivered later as a task, that shared references and cycles inside one message survive, and that identity across separate messages does not.

for a senior

Show you design around copy semantics: decide which side owns long-lived state, define an explicit message contract, and handle failure modes such as a DataCloneError on post and a messageerror on receive rather than assuming delivery.

for a principal

Own the thread boundary as an API surface. Argue for payload shapes that stay cheap to copy and tolerant of version skew between page and worker code, and make state ownership explicit so the copy never becomes a hidden source of divergence.

## The only channel a worker has A dedicated Web Worker runs on its own thread with its own global scope. It cannot reach into the page's variables and the page cannot reach into its. The single sanctioned channel between them is message passing: the page holds a `Worker` object and calls `worker.postMessage(value)`; the worker calls `self.postMessage(value)` back. Each side listens for a `message` event and reads the payload from the event's `data` property. ```js // page const worker = new Worker('/echo.worker.js'); worker.onmessage = (event) => console.log(event.data); worker.postMessage({ count: 1 }); // inside echo.worker.js self.onmessage = (event) => self.postMessage(event.data.count + 1); ``` Note the shape: the handler receives an **event object**, not the payload. Reading the first argument as if it were the message itself (`worker.onmessage = (msg) => msg.count`) is the single most common beginner bug here. ## Copy, not reference When you call `postMessage`, the browser does not hand the receiving thread a pointer. It runs the *structured clone* algorithm: it walks the value you passed and builds an independent reconstruction of it for the other side. That reconstruction is what lands in `event.data`. Two things follow immediately. First, **the copy is taken at the call**, synchronously, before `postMessage` returns. Anything you do to the original afterwards is invisible to the receiver: ```js const payload = { count: 1 }; worker.postMessage(payload); payload.count = 2; // too late — the clone already recorded 1 ``` Second, **the two graphs are permanently disconnected**. The worker can mutate its copy freely; the page will never see it. There is no live binding, no proxying, no write-back. If both sides need to agree on evolving state, they have to exchange messages about it, or one side has to be the owner and the other has to ask. ## What survives inside one message Within a *single* message the clone is faithful about the shape of the graph, not just its leaves. If the same object appears twice inside the value you post, the receiver also gets it twice as one shared object; cyclic structures are reproduced as cycles rather than causing infinite recursion. That is why structured clone exists at all instead of the browser simply calling `JSON.stringify` for you. What it does **not** do is preserve identity *across* messages. Post the same object twice and the receiver gets two unrelated copies. Post a value and then post it again after mutating it, and the receiver sees two snapshots, in order. Not every value can be cloned; posting something the algorithm cannot reconstruct throws a `DataCloneError` at the `postMessage` call rather than failing silently later. Symmetrically, if a message arrives that the receiver cannot deserialize, the receiver gets a `messageerror` event instead of `message` — worth wiring up in production code, because otherwise such a failure is completely invisible. ## Delivery is a task, not a call `postMessage` returns as soon as the copy is made. The message is then queued on the receiving side and dispatched as a task on that thread's event loop. So the receiver's handler runs some time later, and — importantly — only when the receiving thread is free to run a task. If the worker is in the middle of a long synchronous computation, your message sits in its queue until that computation finishes. Message passing does not interrupt anything. Order is preserved per channel: messages posted from one side arrive in the order they were posted. There is no ordering guarantee *between* different channels, and no acknowledgement — `postMessage` is fire-and-forget, so if you need to know that the other side received or processed something, that has to be an explicit reply message you design yourself. ## Designing around it Because every exchange is a copy, the interesting design question stops being "how do I share this object" and becomes "who owns this state". The usual answer is that the worker owns the expensive thing — a parsed index, a loaded dataset, an initialized engine — and the page sends small instructions and receives small results. The moment you find yourself shipping the same large structure back and forth so both sides can "see" it, you have chosen the wrong owner, and you will pay for the copy on every message.

  • How does the worker send something back, and how do you know on the page which request a reply belongs to?
    The worker calls `self.postMessage(value)` and the page reads it from `event.data` on the `Worker` object's `message` event. Nothing in the platform correlates a reply with a request — a single handler sees every reply. If several requests can be in flight, you have to put a correlation field in the message yourself and match replies to it.
  • What happens if you post a value the browser cannot clone?
    `postMessage` throws a `DataCloneError` DOMException synchronously at the call site, so the message is never queued and the receiver hears nothing. That is a useful property: the failure surfaces on the thread that made the mistake, with a stack, rather than as a silently missing message on the other side.
  • If the worker is busy running a long computation, what happens to a message the page posts to it?
    It is queued and delivered as a task once the worker's thread finishes what it is doing. Messages never interrupt running code — the worker is single-threaded internally too. This is why a "cancel" message cannot stop a synchronous loop already in progress; the worker has to check for cancellation between chunks of work, or the page has to terminate it.

saying these in an interview costs you the question

  • Says page and worker share the same object
  • Expects the worker to see mutations made after posting
  • Thinks postMessage delivers synchronously to the other thread
  • Reads the handler's argument directly instead of event.data
  • Assumes a posted message interrupts code the worker is running

context

open as a page

A page calls navigator.serviceWorker.register('/sw.js') on a visitor's first visit, and that worker has a fetch handler. Does the worker intercept the requests of the page load that registered it? Explain what decides whether a document is controlled.

level: juniorimportance: must knowfreq 65%

basics

~20 s

No. A document's controller is chosen when it is navigated to, and on a first visit no worker is active yet, so navigator.serviceWorker.controller is null and every request on that load goes straight to the network.

open as a page

In a browser, what is the difference between calling `worker.postMessage(buffer)` and `worker.postMessage(buffer, [buffer])` for an ArrayBuffer — what does the worker receive in each case, and what is left in the sending context?

level: juniorimportance: must knowfreq 55%

basics

~20 s

Without a transfer list the ArrayBuffer is copied, so both sides hold their own bytes. With the buffer in the transfer list its memory is handed over instead: the worker gets the original bytes and the sender's buffer is detached, byteLength 0.

open as a page

Inside a dedicated Web Worker in the browser, why can you not touch the page's DOM, and which browser APIs are still available on the worker's global scope?

level: juniorimportance: must knowfreq 80%

basics

~20 s

A dedicated Web Worker runs on its own thread with its own global, DedicatedWorkerGlobalScope, so window, document and the DOM are absent — the DOM is not thread-safe. Workers still get fetch, WebSocket, indexedDB, timers and crypto.

open as a page

A dedicated Web Worker replies through a single message event on the page, so every reply lands in the same handler. How would you build a promise-based request/response API on top of postMessage so that several concurrent callers each get their own answer?

level: middleimportance: must knowfreq 46%

basics

~20 s

Attach a unique id to every outgoing message and keep a map from id to that request's promise resolvers. The worker echoes the id back in its reply; the single message handler looks up the id, settles that promise, and deletes the entry.

open as a page

In a service worker's fetch handler, what do the cache-first, network-first and stale-while-revalidate strategies each do, and which one would you apply to hashed JS bundles, the HTML document, and a JSON API response?

level: middleimportance: must knowfreq 78%

basics

~20 s

Cache-first answers from storage and only hits the network on a miss; network-first tries the network and falls back to the stored copy; stale-while-revalidate serves the cached copy instantly and refreshes it in the background. Match them to hashed bundles, HTML, and API JSON respectively.

open as a page

You ship a new build with a changed sw.js. The browser downloads and installs it, yet returning users keep getting the old app until they close every tab. Walk through the registration states that produce that, and what has to happen for the new worker to take over.

level: middleimportance: must knowfreq 70%

basics

~20 s

The new worker installs and then stops in the waiting state because the old worker still controls open pages. It activates only when every client controlled by the old worker is gone — closing all tabs in scope, not reloading them — or when it calls skipWaiting().

open as a page

Inside a service worker, what does event.waitUntil(promise) do in the install and activate events, and what goes wrong if you start async work without it?

level: middleimportance: must knowfreq 60%

basics

~20 s

waitUntil() extends the lifetime of a service worker event: it keeps the worker alive until the promise settles and holds the lifecycle at that stage. Without it the browser may treat install or activate as finished — and terminate the worker — while your async work is still running.

open as a page

A team ships a service worker that answers every request cache-first, including the HTML document at /. After the next deploy, users keep loading the old app even after reloading the page. Explain why, and how you would change the caching strategy.

level: seniorimportance: must knowfreq 62%

basics

~20 s

The document's URL never changes, so cache-first keeps answering it from the stored copy and never sees the new deploy. That old HTML also references the old bundle hashes, pinning the whole app. Fix it by serving navigations network-first and reserving cache-first for hashed URLs.

open as a page

What is the web app manifest, and which of its fields does the browser use to decide an installed app's launch URL, window appearance, and icon?

level: juniorimportance: should knowfreq 58%

basics

~20 s

A web app manifest is a JSON file that describes a site as an installable app. start_url sets the launch URL, scope bounds which pages stay in the app window, display picks standalone or browser chrome, and icons supply launcher artwork.

open as a page

In a service worker, what does the stale-while-revalidate caching strategy do when a request comes in, and what does that mean for what the user actually sees on screen?

level: juniorimportance: should knowfreq 52%

basics

~20 s

It answers the request from the cache straight away and, in parallel, fetches from the network and stores the fresh copy. The user sees the previous version instantly; the new data appears only on the next request, unless the app re-renders when the update lands.

open as a page

In a browser, how do you use MessageChannel and MessagePort to give two parties — for example a page and a Web Worker, or two workers — a private two-way link, and what has to happen before messages start flowing on a port?

level: middleimportance: should knowfreq 28%

basics

~20 s

Create a MessageChannel, keep port1, and hand port2 to the other party inside a postMessage whose transfer list contains that port. Each side then posts on its own port. A port that uses addEventListener must also have start() called, or messages queue and never fire.

open as a page

What does the Background Sync API's `sync` event give you that retrying a failed request inside the page does not, and how do you register one?

level: middleimportance: should knowfreq 34%

basics

~20 s

Background Sync hands a retry to the browser: you call registration.sync.register('tag') and the browser fires a sync event in the service worker once it has connectivity, even after the page is closed. Page-level retries die with the tab.

open as a page

Chromium browsers fire a `beforeinstallprompt` event on `window`. How do you use it to drive your own "Install app" button, and what restrictions apply to calling `prompt()`?

level: middleimportance: should knowfreq 44%

basics

~20 s

Listen for beforeinstallprompt, call preventDefault() to suppress the browser's own UI, and keep the event object. Show your install button, and inside a user gesture call the saved event's prompt(), then await userChoice. The event can be prompted only once.

open as a page

How does a web page subscribe a user to push notifications with the Push API, and what does your application server need from the resulting `PushSubscription` in order to send a message?

level: middleimportance: should knowfreq 40%

basics

~20 s

Call registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) on a service worker registration after notification permission is granted. The returned PushSubscription carries an endpoint URL plus p256dh and auth keys; the server needs all three to encrypt and deliver a message.

open as a page

A build tool generates a service worker precache manifest whose entries look like { url, revision }. Why does the manifest carry a revision alongside the URL, and why is each deploy's precache given a new cache name rather than overwriting entries in one long-lived cache?

level: middleimportance: should knowfreq 45%

basics

~20 s

The revision is a content fingerprint that tells the worker an unhashed URL's bytes changed, so it re-downloads only what differs. A per-build cache name keeps each deploy's asset set intact and self-consistent, and lets the new generation delete the previous one wholesale instead of leaving orphans.

open as a page

A service worker script is served from /static/js/sw.js and the page calls navigator.serviceWorker.register('/static/js/sw.js', { scope: '/' }). Why does that registration fail, and what would make it work?

level: middleimportance: should knowfreq 50%

basics

~20 s

A worker may only control URLs at or below the directory its script is served from, so a script at /static/js/ cannot claim the scope /. The register() promise rejects with a SecurityError unless the script's HTTP response carries the Service-Worker-Allowed header naming the wider scope.

open as a page

A worker replies with `self.postMessage(u8, [u8])`, where `u8` is a `Uint8Array`, and the browser throws a `DataCloneError`. Why does that call fail, and what is the correct way to hand the bytes over without copying them?

level: middleimportance: should knowfreq 32%

basics

~20 s

Typed arrays are not transferable objects — only the ArrayBuffer behind them is. Passing a view in the transfer list throws DataCloneError. Transfer u8.buffer instead: postMessage(u8, [u8.buffer]), which sends the view and moves its memory.

open as a page

In the browser, what changes when you construct a Web Worker as `new Worker(url, { type: 'module' })` instead of the default `new Worker(url)`, and why does `importScripts()` stop working in the module case?

level: middleimportance: should knowfreq 52%

basics

~20 s

The default is a classic worker, which loads dependencies with the synchronous importScripts(). Passing type:'module' runs the worker script as an ES module with static and dynamic import, always in strict mode; importScripts() is not available there and throws.

open as a page

In the browser Worker API, what is the difference between calling `worker.terminate()` from the page and calling `self.close()` inside the worker, and what happens to work that is still in flight?

level: middleimportance: should knowfreq 45%

basics

~20 s

terminate() is the page killing the worker immediately from outside: the running script is aborted mid-execution and no cleanup runs. self.close() is the worker retiring itself: it discards its queued tasks and accepts no more, but the statements after the call still finish.

open as a page

A team moved a per-item computation into a dedicated Web Worker, posting each item to the worker and posting each result back, and the page got slower rather than faster. What cost are they paying on every message, and how would you fix it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Each message pays a structured clone: a synchronous serialize on the sender's thread plus a deserialize on the receiver's, twice per round trip, plus a task dispatch on each side. With tiny work per item that overhead dominates. Fix it by batching many items into one message.

open as a page

A service that sends web push notifications sees delivery quietly decline over months, with the push service answering 404 and 410 for many stored endpoints. Why do push subscriptions become invalid, and how should the client and server handle it?

level: seniorimportance: should knowfreq 26%

basics

~20 s

Push subscriptions are perishable: revoked permission, cleared site data, browser reinstalls, a rotated VAPID key or push-service expiry all invalidate them. Servers must delete endpoints that return 404 or 410, and clients should re-read getSubscription() on each launch and re-register it.

open as a page

A service worker handles navigation requests network-first and falls back to a cached /offline.html. On a cold visit the page feels slower than it did before the worker existed. What causes that delay, and what does navigation preload do about it?

level: seniorimportance: should knowfreq 33%

basics

~20 s

When the worker is not already running, the browser must boot it and evaluate its script before the fetch handler can even start the network request, so startup time is added in front of the document. Navigation preload makes the browser issue that request in parallel with worker startup.

open as a page

A broken service worker is live and returning visitors keep getting a broken app even after you redeploy. How does the browser decide it has a new worker script, and how do you actually kill a bad worker for users who already carry it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The browser re-fetches the worker script on navigations in scope and compares it byte for byte with the stored copy, so an unchanged or HTTP-cached script means no update. To kill a bad worker, ship a replacement sw.js that calls skipWaiting and clients.claim, then unregisters itself and reloads its clients.

open as a page

In a browser page, `self.crossOriginIsolated` is false and `SharedArrayBuffer` is unavailable to your code. Which two response headers does the top-level document need in order to change that, and what typically breaks once you send them?

level: seniorimportance: should knowfreq 40%

basics

~10 s

The document must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Then self.crossOriginIsolated becomes true and SharedArrayBuffer works. The cost: cross-origin subresources without CORP or CORS headers stop loading, and popups lose their opener link.

open as a page

A team moved a 400 ms parse-and-transform step off the main thread into a dedicated Web Worker, and the interaction still feels janky. What costs does moving work into a worker introduce, and how do you decide between a worker and splitting the work up on the main thread?

level: seniorimportance: should knowfreq 44%

basics

~20 s

A worker adds thread startup, a data handoff whose cost scales with payload size, and a round trip of latency. It only helps if the main thread's long task was the computation itself — if the remaining jank is DOM work or rendering the result, the worker moved the wrong 400 ms.

open as a page

For a single-page app that lazily loads content-hashed JavaScript chunks, would you have the service worker call self.skipWaiting() on every install? Lay out the tradeoff against leaving the new worker waiting and prompting the user to reload.

level: principalimportance: should knowfreq 35%

basics

~20 s

Usually not. skipWaiting() activates the new worker under pages that were loaded from the previous build, so a running page can ask for chunk URLs the new worker no longer knows about. For a chunked SPA, prefer leaving it waiting and prompting the user to reload.

open as a page

In a browser, what problem does BroadcastChannel solve that posting to a specific worker or holding a MessagePort does not, and what are its delivery rules?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

BroadcastChannel is same-origin fan-out by name rather than by reference: any context that opened a channel with the same name receives the message, with no handle to each other. The sending object never receives its own message, and there is no history for late joiners.

open as a page

In a browser, `Atomics.wait()` throws a TypeError when called on the main thread, but the same call inside a dedicated Web Worker works. Why is that, and what do you use on the main thread instead?

level: seniorimportance: nice to knowfreq 24%

basics

~20 s

Atomics.wait blocks the calling agent until notified. The browser's main-thread agent is not allowed to block — freezing it would freeze rendering and input — so the call throws TypeError there. Use Atomics.waitAsync, or have a worker do the waiting.

open as a page

Background Sync is implemented only in Chromium browsers. For a field-service web app whose technicians submit reports from areas with poor connectivity, on Android and iOS alike, how would you design the offline write path?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Build a durable outbox in IndexedDB with server-side idempotency keys, and drive it from a ladder of triggers every browser has: immediate send, the online event, visibility changes, and app launch. Treat Background Sync as an extra trigger on Chromium, never the foundation.

open as a page

showing 1–30 of 33