DNA🌐 Web BrowserWeb Workers & Service Workers
ðŸĶ–DinosaurBrowserPerformanceWorkers

Web Workers & Service Workers

JavaScript is single-threaded — until it isn't. Workers let you run code off the main thread, and Service Workers give you control over the network layer.

Web Workers & Service Workers

JavaScript's single-threaded nature is its biggest constraint. Workers break that constraint — they run JavaScript on separate threads, freeing the main thread for UI work. Service Workers go further, acting as a programmable proxy between your app and the network.

The Threading Model

Main Thread                    Worker Threads
┌─────────────────┐           ┌─────────────────┐
│  DOM access     │           │  No DOM access   │
│  Event handlers │  message  │  Heavy compute   │
│  UI rendering   │ ◄───────▹ │  Data processing │
│  Layout/Paint   │  passing  │  Image/Video     │
└─────────────────┘           └─────────────────┘
                              ┌─────────────────┐
                              │  Service Worker  │
                              │  Network proxy   │
                              │  Push notifs     │
                              │  Background sync │
                              └─────────────────┘

Web Workers (Dedicated Workers)

A Web Worker runs a JavaScript file in a separate thread. Communication happens via postMessage:

Basic Setup

// main.js
const worker = new Worker('/worker.js');
 
worker.postMessage({ type: 'PROCESS', data: largeDataset });
 
worker.onmessage = (event) => {
  console.log('Result:', event.data);
};
 
worker.onerror = (error) => {
  console.error('Worker error:', error.message);
};
 
// Terminate when done
worker.terminate();
// worker.js
self.onmessage = (event) => {
  const { type, data } = event.data;
 
  if (type === 'PROCESS') {
    const result = heavyComputation(data);
    self.postMessage(result);
  }
};
 
function heavyComputation(data) {
  return data
    .filter(item => item.score > threshold)
    .map(item => ({ ...item, rank: computeRank(item) }))
    .sort((a, b) => b.rank - a.rank);
}

What Workers CAN'T Do

  • Access the DOM (document, window.document)
  • Access window directly (use self instead)
  • Run synchronous XHR (deprecated anyway)
  • Access parent's variables or scope

What Workers CAN Do

  • fetch() — Make network requests
  • IndexedDB — Read/write database
  • WebSocket — Maintain connections
  • crypto — Cryptographic operations
  • setTimeout/setInterval — Timers
  • importScripts() — Load additional scripts
  • postMessage() — Communicate with main thread

Real-World Use Cases

// 1. Heavy data processing
worker.postMessage({
  type: 'SORT_AND_FILTER',
  data: tenThousandRows,
  filters: activeFilters,
  sortBy: 'date',
});
 
// 2. Image processing
worker.postMessage({
  type: 'RESIZE_IMAGE',
  imageData: canvas.getImageData(0, 0, width, height),
  targetWidth: 200,
});
 
// 3. Search indexing
worker.postMessage({
  type: 'BUILD_INDEX',
  documents: allDocuments,
});
 
// 4. CSV/JSON parsing
worker.postMessage({
  type: 'PARSE_CSV',
  rawText: csvContent,  // 50MB CSV file
});

React Integration

function useWorker<TInput, TOutput>(workerUrl: string) {
  const workerRef = useRef<Worker | null>(null);
  const [result, setResult] = useState<TOutput | null>(null);
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    workerRef.current = new Worker(workerUrl);
    workerRef.current.onmessage = (e: MessageEvent<TOutput>) => {
      setResult(e.data);
      setLoading(false);
    };
    return () => workerRef.current?.terminate();
  }, [workerUrl]);
 
  const run = useCallback((data: TInput) => {
    setLoading(true);
    workerRef.current?.postMessage(data);
  }, []);
 
  return { result, loading, run };
}
 
// Usage
function DataProcessor({ data }) {
  const { result, loading, run } = useWorker<RawData, ProcessedData>('/workers/processor.js');
 
  return (
    <div>
      <button onClick={() => run(data)} disabled={loading}>
        {loading ? 'Processing...' : 'Process Data'}
      </button>
      {result && <ResultsTable data={result} />}
    </div>
  );
}

Transferable Objects

For large data (ArrayBuffers, ImageData), use transfer instead of copy:

// ❌ Copying — duplicates the data (slow for large buffers)
worker.postMessage({ buffer: largeArrayBuffer });
 
// ✅ Transfer — moves ownership to worker (instant, zero-copy)
worker.postMessage({ buffer: largeArrayBuffer }, [largeArrayBuffer]);
// largeArrayBuffer is now neutered in main thread (length = 0)

Transfer is O(1) regardless of data size. Use it for:

  • ArrayBuffers (raw binary data)
  • MessagePorts
  • ImageBitmap
  • OffscreenCanvas

Shared Workers

A single worker shared across multiple tabs/windows of the same origin:

// Any tab
const shared = new SharedWorker('/shared-worker.js');
shared.port.start();
shared.port.postMessage('hello from tab');
shared.port.onmessage = (e) => console.log('Received:', e.data);
// shared-worker.js
const ports = [];
 
self.onconnect = (event) => {
  const port = event.ports[0];
  ports.push(port);
 
  port.onmessage = (e) => {
    // Broadcast to all connected tabs
    ports.forEach(p => p.postMessage(e.data));
  };
};

Use cases: Cross-tab state sync, shared WebSocket connection, shared cache.

Service Workers

A Service Worker is a programmable network proxy that sits between your app and the network. It's the foundation of Progressive Web Apps (PWAs).

Lifecycle

Register → Install → Activate → Fetch/Push/Sync
             │          │
         Cache assets  Clean old caches
// Registration (in main app)
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered:', reg.scope))
    .catch(err => console.error('SW failed:', err));
}
// sw.js — Install event (cache critical assets)
const CACHE_NAME = 'v2';
const CRITICAL_ASSETS = ['/', '/styles/main.css', '/scripts/app.js', '/offline.html'];
 
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(CRITICAL_ASSETS))
      .then(() => self.skipWaiting())
  );
});
 
// Activate event (clean old caches)
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(names => {
      return Promise.all(
        names
          .filter(name => name !== CACHE_NAME)
          .map(name => caches.delete(name))
      );
    }).then(() => self.clients.claim())
  );
});

Fetch Interception Strategies

// Strategy 1: Cache First (static assets)
self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'image' ||
      event.request.destination === 'style' ||
      event.request.destination === 'script') {
    event.respondWith(
      caches.match(event.request).then(cached => {
        return cached || fetch(event.request).then(response => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
          return response;
        });
      })
    );
  }
});
 
// Strategy 2: Network First (API data)
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
          return response;
        })
        .catch(() => caches.match(event.request))
    );
  }
});
 
// Strategy 3: Stale While Revalidate
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      const fetchPromise = fetch(event.request).then(response => {
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, response.clone()));
        return response;
      });
      return cached || fetchPromise;
    })
  );
});

Offline Fallback

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match('/offline.html'))
    );
  }
});

Background Sync

// Main app: queue action for when online
navigator.serviceWorker.ready.then(reg => {
  return reg.sync.register('sync-messages');
});
 
// Service Worker: execute when connection available
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(sendQueuedMessages());
  }
});

Decision Guide: Which Worker?

NeedSolution
Heavy computation (sorting, parsing, crypto)Web Worker
Share state across tabsShared Worker
Offline support, cachingService Worker
Network interception, proxyService Worker
Push notificationsService Worker
Background syncService Worker
Real-time data processingWeb Worker

Interview Signal

Senior candidates demonstrate:

  1. Threading model — Main thread constraints, message passing, no shared memory
  2. Transfer vs copy — Transferable objects for performance
  3. Service Worker lifecycle — Install, activate, fetch, update flow
  4. Caching strategies — Cache-first, network-first, stale-while-revalidate and when each applies
  5. Practical judgment — When the overhead of workers is worth it vs. when it's not