-
Notifications
You must be signed in to change notification settings - Fork 0
Web Worker Isolation
By default every request runs inside a Web Worker created from an inlined blob — no extra file to host, no bundler configuration, no ?worker import.
const api = createClient({ baseUrl }); // worker: true is the default
api.isWorker; // true in a supporting browserTokens live in the worker's closure. They are never posted to the main thread, never written to localStorage, never stored in a page-scope variable.
┌─ Main thread ──────────────┐ ┌─ Web Worker ─────────────────┐
│ │ │ │
│ your app code │ │ CoreClient │
│ api.get("/users") ────────┼─post──▶│ AuthStore { accessToken } │
│ │ │ executeRequest() │
│ ◀── IRes (no tokens) ─────┼────────│ └─ fetch() │
│ ◀── AuthState (no tokens) │ │ │
└────────────────────────────┘ └──────────────────────────────┘
An XSS payload running on your page has no variable to read and no storage key to dump. It cannot exfiltrate the token.
What crosses the boundary:
| Direction | Payload |
|---|---|
| Host → worker | Serializable options, method, URL, body, serializable config |
| Worker → host |
IRes envelopes, AuthState, LogEntry, ready/failure signals |
Never a token, in either direction. Every result is checked before it is posted back: the session's own tokens are removed wherever they appear, and responses from the login and refresh endpoints lose their token fields — the extractor has already captured them into the worker's closure, and the main thread receives the rest of the payload (user, message, …).
scripts/build-worker.ts bundles src/worker/worker-entry.ts into a single minified IIFE and inlines it as a string in src/worker/worker-source.ts. At runtime:
const blob = new Blob([WORKER_SOURCE], { type: "text/javascript" });
const worker = new Worker(URL.createObjectURL(blob));Because the worker bundles the same CoreClient and executeRequest the main thread uses, the two modes cannot drift. A behaviour verified on one is verified on the other; verify/worker.mjs asserts this against a live server.
destroy() terminates the worker and revokes the object URL.
Worker mode is skipped — silently, falling back to the identical main-thread implementation — when any of these hold:
| Condition | Why |
|---|---|
worker: false |
You opted out |
typeof window === "undefined" |
SSR / Node: no Worker
|
Worker, Blob or URL.createObjectURL missing |
Unsupported runtime |
extractTokens supplied |
Functions can't be structured-cloned |
buildRefreshBody supplied |
Same |
| Worker construction throws | Caught, falls back |
Worker fails to boot (CSP blocks blob: — browsers report it asynchronously — or it doesn't start within 10 s) |
Requests switch to the main thread; isWorker becomes false
|
Always check what you actually got:
if (!api.isWorker) console.warn("running on the main thread");Blob workers need:
Content-Security-Policy: worker-src 'self' blob:;
Older browsers fall back to child-src blob:. Without it the worker never starts: whether the browser throws from new Worker(blobUrl) or reports the block later through onerror, the client switches to the main-thread path with no error, and api.isWorker reads false from then on.
extractTokens and buildRefreshBody disable worker mode only when passed as functions — a function cannot be structured-cloned. Their declarative forms are plain data and cross fine:
createClient({
extractTokens: { accessKeys: ["jwt"], refreshKeys: ["renew"], roots: ["result"] },
buildRefreshBody: { field: "refresh_token" },
});Other function options are applied on the host instead, so they keep working:
| Option | Handling |
|---|---|
beforeFunc |
Applied on the main thread before the body is posted in |
afterFunc |
Applied on the main thread after the result comes back, on success only |
beforeSelectOptions |
Same |
getCsrfToken |
Called on the main thread whenever the worker asks for the token |
plugins |
Run on the main thread, around each call |
onAuthStateChanged / onAuthFailure / onError / onLog
|
Invoked on the main thread from worker messages |
Observable behaviour is identical.
The tokens stay where they are. api.login() resolves on the main thread with the sanitized payload, and every other response has the session's tokens removed before it crosses the boundary — so a call to the refresh endpoint, or an endpoint that echoes the bearer token, can't hand the page a token either.
A stream cannot be structured-cloned. Rather than an opaque DataCloneError from postMessage, you get:
A ReadableStream body cannot be sent through a Web Worker. Create this client with
worker: false, or send a Blob/File/FormData instead.
FormData, File, Blob, ArrayBuffer and typed arrays are all transferable and work fine.
Workers have no document. When a request needs the CSRF token, the worker asks the host, which reads document.cookie (or calls your getCsrfToken) and answers. See CSRF Protection.
localStorage, sessionStorage and document.cookie are Window APIs that simply don't exist in a worker. So the adapter — whether a string kind or your own object — is built on the main thread, and the worker persists through it over an internal storage message.
Every kind therefore behaves the same in both modes, and sessions survive a reload.
"memory" is the exception: it stays inside the worker and the bridge is never used, so tokens never reach the main thread at all. That's what makes it the safest option, and it's why it remains the default.
Before v1.0.2 the worker built the adapter itself.
localStoragedoesn't exist there, so"local","session"and"cookie"silently discarded every write and users were logged out on each reload.
AbortSignal is not cloneable, so the host strips it and manages cancellation by message. The cancel registry lives on the host for the same reason — plus api.cancel() has to be synchronous and work before the worker has even booted:
host: api.get(url, { signal })
└─ signal.onabort → postMessage({ kind: "abort", id, reason })
└─ api.cancel(selector) → same message, for every match
worker: controller.abort(CancelError) → the real fetch is aborted
Cancellation is genuine, not cosmetic — the network request really stops and the socket closes. pending() is answered from the host, so it stays synchronous too. See Cancellation.
const api = createClient({ baseUrl });
// → worker constructed, `init` posted, `ready` awaited before the first request
await api.get("/users");
api.destroy();
// → `destroy` posted → worker aborts pending requests and self-closes
// → worker.terminate(), pending promises reject with "Client destroyed"
// → object URL revoked, listeners clearedAlways destroy() short-lived clients (per-request server clients, test setups) to avoid leaking a worker.
| Worker bundle | ~14 KB minified, inlined in the main bundle |
| Startup | One blob + worker construction, a few ms |
| Per request | Two postMessage hops; structured clone of the body and result |
For large binary bodies the clone is a real copy. If you upload very large ArrayBuffers in a tight loop, benchmark against worker: false.
createClient({ worker: false });Reasonable when you're streaming request bodies, you need custom storage, you're on the server, or your CSP forbids blob workers and you'd rather be explicit than rely on the fallback.
Worker isolation prevents token theft. It cannot stop an attacker who already has XSS from using your client to make requests on the user's behalf.
If an attacker controls your page, they can call api.post("/transfer", …) and the worker will happily attach the token. What they cannot do is take the token somewhere else — to another origin, another session, or a long-lived attack after the tab closes.
That is a meaningful reduction in blast radius, not a substitute for a strict CSP, output encoding, and dependency hygiene. See Security Model.
Next: Multi-Tab Sync
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides