awesome-everything RU
↑ Back to the climb

Browser & Frontend Runtime

Web worker mechanics: dedicated, shared, and OffscreenCanvas

Crux Dedicated vs shared vs module workers, the OffscreenCanvas escape hatch, and why the worker script URL must be same-origin.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 14 min

You know workers exist. But new Worker('script.js') spawns a dedicated thread that dies with the tab — so what do you use when five tabs need the same WebSocket connection, or when WebGL rendering should survive main-thread saturation?

Dedicated workers

new Worker('worker.js') spins up a fresh JS realm on its own OS thread, with its own event loop, its own global (DedicatedWorkerGlobalScope), and no access to document, window, or the DOM. It can use fetch, IndexedDB, WebSocket, OffscreenCanvas, timers, and importScripts/ES modules. Communication is exclusively postMessage in both directions.

A worker is tied to the page that created it: close the tab and the worker dies. Use it for anything CPU-bound that would otherwise blow the 50 ms task budget — JSON parsing of large payloads, image filters, cryptographic hashing, diffing, compression, syntax highlighting, physics.

The same-origin constraint. The worker script URL must be same-origin (or a blob URL). You cannot load a worker straight from a third-party CDN without proxying it through your own origin first. This is the same origin boundary that protects the rest of the browser’s security model.

Shared workers

new SharedWorker('s.js') creates a worker that is shared across all tabs of the same origin. Each tab connects through a MessagePort, and the worker lives as long as any tab uses it — useful for one shared WebSocket connection or a coherent cross-tab cache. Support is weaker than dedicated workers (absent in some browser configurations) and debugging is harder, because the worker’s lifetime is decoupled from any single tab.

Module workers

The { type: 'module' } option changes how a worker loads its dependencies:

  • Classic worker — uses importScripts() to load dependencies synchronously.
  • Module worker — uses ordinary static import. This gives tree-shaking, top-level await, and the same module graph as the rest of the app.

Module workers are the modern default. Classic importScripts remains only for legacy code and cases that build the script name dynamically at runtime.

Worker variant comparison
Dedicated worker lifetime
Tied to creating page
Shared worker lifetime
Last subscribing tab
Module worker
{ type: 'module' } option
Worker startup overhead
5–20 ms + script parse
Max pool size (rule of thumb)
hardwareConcurrency − 1

OffscreenCanvas: the one DOM carve-out

A worker cannot touch the DOM, but canvas is a special case — a canvas bitmap is not part of the DOM tree, it is just a pixel buffer. OffscreenCanvas is the canvas decoupled from a <canvas> element.

The pattern:

  1. Main thread calls canvas.transferControlToOffscreen(), gets an OffscreenCanvas.
  2. Transfers it to a worker through postMessage’s transfer list.
  3. Worker gets a 2D or WebGL context and renders entirely on its own thread.
  4. The browser reflects the result into the visible <canvas> — with no main-thread involvement.

This makes smooth 60 fps WebGL visualisations, map rendering, and games possible even while the main thread is saturated.

A worker can also create a standalone new OffscreenCanvas(w, h) purely for off-screen rendering — texture generation, image processing — and return the result as a transferable ImageBitmap.

The exclusivity constraint. An OffscreenCanvas whose control was transferred cannot also be drawn from the main thread — ownership is exclusive, exactly like a transferred ArrayBuffer. If both threads need to draw, you need two canvases or a different architecture; the platform deliberately forbids two threads racing on one bitmap.

Quiz

Five browser tabs from the same origin each need a single shared WebSocket connection managed in one place. Which worker type fits?

Quiz

After `canvas.transferControlToOffscreen()`, the main thread tries to call `ctx.fillRect(0,0,100,100)`. What happens?

Complete the analogy

A worker that is shared across all tabs of the same origin — each tab connects through a separate port — is called what?

Why this works

Why does OffscreenCanvas exist if workers cannot touch the DOM? Canvas is special because its output is a flat bitmap — it has no DOM events, no CSS, no accessibility tree. The browser’s rendering pipeline reads the bitmap and composites it; nothing else in the DOM tree cares who wrote the pixels. This makes it possible to safely hand pixel-writing to any thread. Generalising this to all DOM would require locking the entire tree — which is exactly what the worker model avoids.

Recall before you leave
  1. 01
    What are the three worker variants and when do you use each?
  2. 02
    Why must a worker script be same-origin (or a blob URL)?
  3. 03
    After calling canvas.transferControlToOffscreen(), can the main thread still draw to that canvas?
Recap

A dedicated worker is a fresh JS realm, event loop, and OS thread tied to the page that created it — it dies when the page closes. A shared worker spans all tabs of an origin via MessagePort connections, surviving as long as any tab uses it. Module workers use ES imports instead of the legacy importScripts. OffscreenCanvas is the one way workers can render: transfer a canvas’s control to a worker and it draws 2D or WebGL on its own thread, with the browser compositing the result — but ownership is exclusive and transfer is permanent.

Connected lessons
appears again in41
Continue the climb ↑Structured clone and transferables
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.