webroad.online
  1. 1Web
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Tooling
  8. 8React
  9. 9State management
  10. 10Next.js
  11. 11Forms
  12. 12Data and backend
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Animations
  16. 16Testing
  17. 17Architecture
JavaScript · Lesson 18 of 18

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 worker

What 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 the waiting state 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 in activate;
  • self.skipWaiting() in install and self.clients.claim() in activate, so the new SW takes over right away; or, more politely, a "New version, reload" banner that sends skipWaiting only on click;
  • serve sw.js with Cache-Control: no-cache, so the browser always checks whether it changed;
  • as a kill switch, a new sw.js that calls self.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 worker

Service 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 async doesn't help. Move it to a Web Worker, with postMessage and transferred ArrayBuffers.
  • A worker has no DOM or window, but it has fetch, 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.

Official sources

Exercises

Was this page helpful?

One tap — no account needed.