Web Workers, Service Workers și alți workeri
Main thread-ul, Web Workers cu postMessage, Service Workers pentru cache și offline, plus ce înseamnă „worker” pe edge.
Actualizat
Main thread-ul și de ce îngheață pagina
JS-ul paginii rulează pe main thread, același thread care desenează pagina, răspunde la click-uri și rulează React. Event loop-ul ia următorul task doar când cel curent s-a terminat. O buclă de 2 secunde (un filtru pe o imagine, parsarea unui CSV mare, o căutare fuzzy în 50.000 de rânduri) înseamnă 2 secunde fără click-uri, fără scroll fluid și fără animații din JS.
async/await nu rezolvă asta. Un Promise doar amână codul, care rulează tot pe main thread. Ca să rulezi cu adevărat în paralel, ai nevoie de alt thread, adică de un worker.
Încearcă: același calcul de 2 secunde, o dată pe main thread și o dată într-un worker.
Arată codul
<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
Un Web Worker e un script care rulează pe un thread separat, cu propriul event loop. Nu împarte variabile cu pagina: comunicați doar prin mesaje.
// main.js: pagina
const worker = new Worker(new URL('./primes.worker.js', import.meta.url), { type: 'module' })
worker.postMessage(2_000_000) // trimite date
worker.onmessage = event => { // primește rezultatul
document.querySelector('.result').textContent = event.data
}
worker.onerror = event => console.error(event.message)// primes.worker.js: worker-ul
import { countPrimes } from './math.js' // { type: 'module' } permite import
self.onmessage = event => {
self.postMessage(countPrimes(event.data))
}worker.terminate() oprește worker-ul imediat, din pagină (sau self.close() din interior). Fă asta când componenta dispare sau când calculul nu mai e necesar, altfel thread-ul rămâne în memorie.
Ce primește worker-ul: structured clone și transferables
postMessage copiază datele cu algoritmul structured clone. Merg obiecte, array-uri, Map, Set, Date, Blob, ArrayBuffer. Nu merg funcțiile și nodurile DOM (aruncă DataCloneError), iar instanțele de clase ajung ca obiecte simple, fără metode.
Copierea a 100 MB costă timp. Un ArrayBuffer se poate transfera: memoria trece la worker fără copie, iar în pagină buffer-ul rămâne gol.
const pixels = new Uint8ClampedArray(width * height * 4)
worker.postMessage(pixels.buffer, [pixels.buffer]) // al doilea argument: lista de transferat
pixels.byteLength // 0, buffer-ul aparține acum worker-uluiCe poate și ce nu poate un worker
| Nu are | Are |
|---|---|
document, DOM, window |
fetch, WebSocket |
localStorage, alert |
setTimeout, setInterval |
| acces la variabilele paginii | IndexedDB, Cache API |
importScripts (clasic) sau import (module) |
Regula: worker-ul calculează, pagina afișează.
Tipurile de workeri
| Tip | Ce face | Durata de viață | Când îl folosești |
|---|---|---|---|
Dedicated Worker (new Worker) |
thread pentru o singură pagină | cât pagina, sau până la terminate() |
calcule grele: imagini, parsare, criptare, căutare |
Shared Worker (new SharedWorker) |
un singur worker comun pentru toate tab-urile aceluiași site | cât e deschis măcar un tab | o conexiune WebSocket comună pentru toate tab-urile; verifică suportul pe mobil |
| Service Worker | proxy între pagină și rețea | pornit și oprit de browser, după nevoie | cache, offline, push, PWA |
| Worklets (Audio, Paint) | mini-scripturi rulate în pipeline-ul de audio sau de desenare | controlată de browser | procesare audio în timp real, desen CSS custom cu paint() (doar în Chromium) |
Service Workers
Un Service Worker stă între pagină și rețea: fiecare request al paginii (HTML, CSS, imagini, fetch) trece prin evenimentul lui fetch, iar el decide dacă răspunde din cache, din rețea sau cu altceva. Funcționează doar pe HTTPS (și pe localhost, pentru development), pentru că un proxy injectat pe HTTP ar fi un cadou pentru atacatori.
register() din pagină→install -> pune în cache fișierele de bază→activate -> șterge cache-urile vechi→fetch -> interceptează request-urile// în pagină
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: un SW controlează doar URL-urile din folderul lui și de sub el. /sw.js controlează tot site-ul, /blog/sw.js doar /blog/.... De aceea fișierul stă de obicei la rădăcină.
Strategii de cache
| Strategie | Cum | Potrivită pentru |
|---|---|---|
| cache-first | cache, iar dacă lipsește, rețea | fișiere cu hash în nume (app.3f9a.js), fonturi, imagini |
| network-first | rețea, iar dacă pică, cache | HTML, date care trebuie să fie proaspete |
| stale-while-revalidate | răspunde din cache și actualizează cache-ul din rețea, pentru data viitoare | avatare, feed-uri, API-uri unde o versiune puțin veche e OK |
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 și push
Un SW care servește pagini din cache plus un web app manifest (manifest.json, cu nume și iconițe) fac din site un PWA: se poate instala pe ecran și pornește și fără internet. Tot SW-ul primește notificările push: pagina se abonează cu registration.pushManager.subscribe(...), serverul trimite mesajul prin Web Push, iar SW-ul îl afișează în evenimentul push cu self.registration.showNotification(...), chiar dacă site-ul e închis. Pe iPhone, push-ul merge doar pentru un PWA adăugat pe ecranul principal.
Pericolul: cache-ul vechi
Cea mai frecventă problemă cu un SW: ai publicat o versiune nouă, dar userii o văd tot pe cea veche. De ce:
- SW-ul vechi servește HTML-ul cache-first, deci pagina nici nu ajunge să afle de versiunea nouă;
- browserul descarcă noul
sw.js, dar acesta stă în stareawaitingpână se închid toate tab-urile controlate de cel vechi; un simplu refresh nu ajunge.
Cum previi:
- network-first pentru HTML, cache-first doar pentru fișierele cu hash în nume;
- un nume nou de cache la fiecare versiune (
static-v3), iar înactivatele ștergi pe cele vechi; self.skipWaiting()îninstallșiself.clients.claim()înactivate, ca SW-ul nou să preia controlul imediat; sau, mai politicos, un banner „Versiune nouă, reîncarcă” care trimiteskipWaitingdoar la click;sw.jsservit cuCache-Control: no-cache, ca browserul să verifice mereu dacă s-a schimbat;- ca kill switch, un
sw.jsnou care faceself.registration.unregister().
În DevTools → Application → Service Workers vezi starea, poți forța actualizarea și poți bifa „Update on reload” cât timp lucrezi.
Workerii în practică
RPC în loc de mesaje. Când worker-ul are mai multe funcții, postMessage cu { type, payload } devine repede un switch mare. Comlink (de la Google Chrome Labs) le transformă în apeluri async obișnuite:
// 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) // rulează în workerService Workers fără cod scris de mână. Workbox dă strategiile gata făcute (new CacheFirst(), new StaleWhileRevalidate(), precache la build). Pentru Next.js, ghidul oficial de PWA menționează Serwist, un fork modern al Workbox.
Bundlerele și new URL(..., import.meta.url). Vite, webpack 5 și Turbopack (în Next.js) recunosc exact tiparul new Worker(new URL('./file.js', import.meta.url)) și construiesc worker-ul ca fișier separat, cu importurile lui. Scrie URL-ul direct în apel, nu într-o variabilă, altfel bundlerul nu-l vede. În Next.js, un worker se creează doar într-un Client Component, în useEffect (pe server nu există Worker), iar în funcția de cleanup apelezi worker.terminate().
„Workeri” pe server: alt sens al cuvântului
Cloudflare Workers și Vercel Functions nu au legătură cu thread-urile din browser. Sunt funcții care rulează pe server sau pe edge, aproape de user, și primesc un Request și returnează un Response. API-ul Cloudflare Workers a pornit chiar de la cel al Service Worker-ilor, de aici și numele.
export default {
async fetch(request) {
return new Response('Hello from the edge')
},
}Pe scurt
- Main thread-ul face și JS, și UI; calculele grele îl blochează, iar
asyncnu ajută. Mută-le într-un Web Worker, cupostMessageși transfer deArrayBuffer. - Un worker n-are DOM sau
window, dar arefetch, timere și IndexedDB. El calculează, pagina afișează. - Un Service Worker e un proxy de rețea pentru cache, offline și push; versionează cache-ul și folosește network-first pentru HTML, ca să nu rămână userii pe versiunea veche.