Web Workers, Service Workers and other workers
The main thread, Web Workers with postMessage, Service Workers for caching and offline, and what "worker" means at the edge.
Updated
The main thread, and why the page freezes
Your page's JS runs on the main thread, the same thread that paints the page, responds to clicks and runs React. The event loop only picks up the next task once the current one has finished. A 2-second loop (a filter on an image, parsing a big CSV, a fuzzy search over 50,000 rows) means 2 seconds with no clicks, no smooth scrolling and no JS-driven animations.
async/await doesn't fix this. A Promise only postpones code, which still runs on the main thread. To really run things in parallel you need another thread: a worker.
Try it: the same 2 seconds of work, once on the main thread and once in a worker.
Show code
<style>
body { align-content: center; }
.spinner { width: 56px; height: 56px; border-radius: 50%; border: 6px solid rgb(255 252 225 / 0.15); border-top-color: #0ae448; }
.buttons { display: flex; gap: 8px; }
button { font: 600 14px system-ui, sans-serif; padding: 8px 14px; border: 0; border-radius: 8px; cursor: pointer; background: #fffce1; color: #0e100f; }
button.off { background: #0ae448; }
output { font: 13px ui-monospace, monospace; color: #abff84; min-height: 1.4em; text-align: center; }
</style>
<div class="spinner"></div>
<div class="buttons">
<button class="main">Main thread</button>
<button class="off">Web Worker</button>
</div>
<output>2 seconds of math. Watch the spinner.</output>
<script>
const spinner = document.querySelector('.spinner')
const output = document.querySelector('output')
// the spinner is driven by JS, like any interactive UI
let angle = 0
const spin = () => {
angle += 6
spinner.style.transform = `rotate(${angle}deg)`
requestAnimationFrame(spin)
}
requestAnimationFrame(spin)
// the same heavy function, used by both buttons
function heavy(ms) {
const end = performance.now() + ms
let ops = 0
while (performance.now() < end) ops++
return ops
}
// the worker gets the same function as text, through a Blob URL
const source = heavy.toString() + '\nonmessage = e => postMessage(heavy(e.data))'
const worker = new Worker(URL.createObjectURL(new Blob([source], { type: 'text/javascript' })))
worker.onmessage = e => {
output.textContent = `Worker: ${e.data.toLocaleString('en')} ops, the spinner kept spinning`
}
document.querySelector('.main').onclick = () => {
output.textContent = 'Main thread busy...'
setTimeout(() => {
const ops = heavy(2000)
output.textContent = `Main thread: ${ops.toLocaleString('en')} ops, the spinner froze`
}, 50)
}
document.querySelector('.off').onclick = () => {
output.textContent = 'Worker busy...'
worker.postMessage(2000)
}
</script>Web Workers
A Web Worker is a script that runs on a separate thread with its own event loop. It shares no variables with the page: you talk to it only through messages.
// main.js: the page
const worker = new Worker(new URL('./primes.worker.js', import.meta.url), { type: 'module' })
worker.postMessage(2_000_000) // send data
worker.onmessage = event => { // receive the result
document.querySelector('.result').textContent = event.data
}
worker.onerror = event => console.error(event.message)// primes.worker.js: the worker
import { countPrimes } from './math.js' // { type: 'module' } allows import
self.onmessage = event => {
self.postMessage(countPrimes(event.data))
}worker.terminate() stops the worker immediately from the page (or self.close() from inside). Do it when the component goes away or the result is no longer needed, otherwise the thread stays in memory.
What the worker receives: structured clone and transferables
postMessage copies the data using the structured clone algorithm. Objects, arrays, Map, Set, Date, Blob and ArrayBuffer all work. Functions and DOM nodes don't (you get a DataCloneError), and class instances arrive as plain objects without their methods.
Copying 100 MB takes time. An ArrayBuffer can be transferred instead: the memory moves to the worker without a copy, and the page is left with an empty buffer.
const pixels = new Uint8ClampedArray(width * height * 4)
worker.postMessage(pixels.buffer, [pixels.buffer]) // second argument: what to transfer
pixels.byteLength // 0, the buffer now belongs to the workerWhat a worker can and can't do
| It doesn't have | It has |
|---|---|
document, the DOM, window |
fetch, WebSocket |
localStorage, alert |
setTimeout, setInterval |
| access to the page's variables | IndexedDB, the Cache API |
importScripts (classic) or import (module) |
The rule: the worker computes, the page displays.
Kinds of workers
| Kind | What it does | Lifetime | When to use it |
|---|---|---|---|
Dedicated Worker (new Worker) |
a thread for a single page | as long as the page, or until terminate() |
heavy work: images, parsing, crypto, search |
Shared Worker (new SharedWorker) |
one worker shared by every tab of the same site | while at least one tab is open | one WebSocket connection shared by all tabs; check mobile support |
| Service Worker | a proxy between the page and the network | started and stopped by the browser as needed | caching, offline, push, PWA |
| Worklets (Audio, Paint) | tiny scripts run inside the audio or painting pipeline | controlled by the browser | real-time audio processing, custom CSS painting with paint() (Chromium only) |
Service Workers
A Service Worker sits between the page and the network: every request the page makes (HTML, CSS, images, fetch) goes through its fetch event, and it decides whether to answer from the cache, from the network or with something else. It only works over HTTPS (and on localhost for development), because a proxy injected over HTTP would be a gift to attackers.
register() from the page→install -> cache the core files→activate -> delete old caches→fetch -> intercept requests// in the page
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js')// sw.js
const CACHE = 'static-v2'
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(['/offline.html', '/styles.css'])))
})
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))))
})
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')))
})Scope: a SW only controls URLs in its own folder and below. /sw.js controls the whole site, /blog/sw.js only /blog/.... That's why the file usually lives at the root.
Caching strategies
| Strategy | How | Good for |
|---|---|---|
| cache-first | the cache, and the network if it's missing | files with a hash in the name (app.3f9a.js), fonts, images |
| network-first | the network, and the cache if it fails | HTML, data that must be fresh |
| stale-while-revalidate | answer from the cache and update the cache from the network for next time | avatars, feeds, APIs where slightly old data is fine |
async function staleWhileRevalidate(request) {
const cache = await caches.open('api-v1')
const cached = await cache.match(request)
const fresh = fetch(request).then(response => {
if (response.ok) cache.put(request, response.clone())
return response
})
return cached ?? fresh
}Offline, PWA and push
A SW that serves pages from the cache, plus a web app manifest (manifest.json with a name and icons), turns a site into a PWA: it can be installed on the home screen and starts even without internet. The SW is also what receives push notifications: the page subscribes with registration.pushManager.subscribe(...), the server sends the message through Web Push, and the SW shows it in its push event with self.registration.showNotification(...), even when the site is closed. On iPhone, push only works for a PWA added to the home screen.
The danger: a stale cache
The most common SW problem: you shipped a new version, but users still see the old one. Why:
- the old SW serves HTML cache-first, so the page never even learns about the new version;
- the browser downloads the new
sw.js, but it sits in thewaitingstate until every tab controlled by the old one is closed; a plain refresh isn't enough.
How to prevent it:
- network-first for HTML, cache-first only for files with a hash in the name;
- a new cache name for every version (
static-v3), and delete the old ones inactivate; self.skipWaiting()ininstallandself.clients.claim()inactivate, so the new SW takes over right away; or, more politely, a "New version, reload" banner that sendsskipWaitingonly on click;- serve
sw.jswithCache-Control: no-cache, so the browser always checks whether it changed; - as a kill switch, a new
sw.jsthat callsself.registration.unregister().
In DevTools → Application → Service Workers you can see the state, force an update and tick "Update on reload" while you work.
Workers in practice
RPC instead of messages. Once the worker has several functions, postMessage with { type, payload } quickly turns into a big switch. Comlink (from Google Chrome Labs) turns them into ordinary async calls:
// primes.worker.js
import * as Comlink from 'comlink'
Comlink.expose({ countPrimes })
// main.js
const api = Comlink.wrap(new Worker(new URL('./primes.worker.js', import.meta.url), { type: 'module' }))
const total = await api.countPrimes(2_000_000) // runs in the workerService Workers without hand-written code. Workbox gives you ready-made strategies (new CacheFirst(), new StaleWhileRevalidate(), precaching at build time). For Next.js, the official PWA guide mentions Serwist, a modern fork of Workbox.
Bundlers and new URL(..., import.meta.url). Vite, webpack 5 and Turbopack (in Next.js) recognise exactly the new Worker(new URL('./file.js', import.meta.url)) pattern and build the worker as a separate file with its own imports. Write the URL directly in the call, not in a variable, or the bundler won't see it. In Next.js, create a worker only in a Client Component, inside useEffect (there's no Worker on the server), and call worker.terminate() in the cleanup function.
"Workers" on the server: another meaning of the word
Cloudflare Workers and Vercel Functions have nothing to do with browser threads. They're functions that run on a server or at the edge, close to the user: they take a Request and return a Response. The Cloudflare Workers API actually started from the Service Worker API, hence the name.
export default {
async fetch(request) {
return new Response('Hello from the edge')
},
}Summary
- The main thread does both JS and UI; heavy work blocks it, and
asyncdoesn't help. Move it to a Web Worker, withpostMessageand transferredArrayBuffers. - A worker has no DOM or
window, but it hasfetch, timers and IndexedDB. It computes, the page displays. - A Service Worker is a network proxy for caching, offline and push; version your cache and use network-first for HTML so users don't get stuck on an old version.