Skip to content

05 - Browser APIs ​


1. IntersectionObserver Internals ​

What: Asynchronously observes when an element enters/exits the viewport (or any ancestor element). Used for lazy loading, infinite scroll, ad viewability.

tsx
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        loadImage(entry.target);       // lazy load
        observer.unobserve(entry.target); // stop watching
      }
    });
  },
  {
    root: null,           // null = viewport
    rootMargin: '200px',  // start loading 200px before element is visible
    threshold: 0.1,       // trigger when 10% visible
  }
);

document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

Internals:

  • Does NOT run on the main thread — browser handles it internally
  • Observations are batched and delivered asynchronously
  • Uses the compositor thread's knowledge of element positions
  • Much cheaper than scroll event + getBoundingClientRect() (which causes layout thrashing)

React hook pattern:

tsx
function useInView(ref: RefObject<HTMLElement>, options?: IntersectionObserverInit) {
  const [isInView, setIsInView] = useState(false);

  useEffect(() => {
    if (!ref.current) return;
    const observer = new IntersectionObserver(
      ([entry]) => setIsInView(entry.isIntersecting),
      options
    );
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, options]);

  return isInView;
}

2. ResizeObserver Loop Limits ​

What: ResizeObserver watches elements for size changes. But if your observer callback CHANGES an element's size, it triggers another observation, creating an infinite loop.

js
// INFINITE LOOP:
const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    entry.target.style.width = entry.contentRect.width + 10 + 'px';
    // ↑ Changes width → triggers ResizeObserver again → changes width → ...
  }
});

Browser protection: After detecting a loop, the browser throws: ResizeObserver loop completed with undelivered notifications

This is a WARNING, not a fatal error. The browser breaks the loop after one iteration to prevent freezing. But your layout may be incorrect.

Fixes:

  • Don't change observed element's size inside the callback
  • If you must, use requestAnimationFrame to defer the write
  • Use CSS containment (contain: size) to prevent cascading resizes
js
const observer = new ResizeObserver((entries) => {
  requestAnimationFrame(() => { // defers to next frame, breaks the loop
    for (const entry of entries) {
      updateLayout(entry.contentRect);
    }
  });
});

3. MutationObserver Cost ​

What: Watches DOM subtree for changes (child additions/removals, attribute changes, text content changes). Powerful but expensive.

js
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      console.log('Children changed:', mutation.addedNodes, mutation.removedNodes);
    }
  }
});

observer.observe(document.body, {
  childList: true,   // watch for added/removed children
  subtree: true,     // watch ALL descendants (expensive!)
  attributes: true,  // watch attribute changes
  characterData: true, // watch text changes
});

Performance costs:

  • subtree: true on document.body = watching EVERY DOM change on the page
  • Each observed mutation creates a MutationRecord object (memory)
  • Callbacks are microtasks — run before rendering, can block paint
  • React makes many DOM changes per render → observer fires extensively

Best practices:

  • Observe the narrowest possible subtree
  • Only enable the mutation types you need
  • Disconnect when done (observer.disconnect())
  • Debounce the callback if processing is expensive

4. IndexedDB ​

What: A low-level, transactional, key-value database in the browser. Stores structured data (objects, files, blobs) with indexes for fast queries.

tsx
// Open database
const request = indexedDB.open('myapp', 1);

request.onupgradeneeded = (event) => {
  const db = request.result;
  const store = db.createObjectStore('transactions', { keyPath: 'id' });
  store.createIndex('status', 'status'); // index for queries
  store.createIndex('date', 'createdAt');
};

request.onsuccess = () => {
  const db = request.result;

  // Write
  const tx = db.transaction('transactions', 'readwrite');
  tx.objectStore('transactions').put({
    id: 'txn_123',
    amount: 500,
    status: 'success',
    createdAt: Date.now(),
  });

  // Read by index
  const readTx = db.transaction('transactions', 'readonly');
  const index = readTx.objectStore('transactions').index('status');
  const request = index.getAll('pending'); // all pending transactions

  request.onsuccess = () => console.log(request.result);
};

vs localStorage:

FeaturelocalStorageIndexedDB
Storage limit~5MB~50MB-unlimited (with permission)
Data typesStrings onlyObjects, blobs, files, arrays
AsyncNo (blocks main thread)Yes (non-blocking)
IndexesNoYes (queryable)
TransactionsNoYes (ACID)

Use cases: Offline data storage, caching API responses, storing large datasets (transaction history), draft form data that survives page reload.

Wrapper libraries: idb (promisified), Dexie.js (query builder).


5. Web Workers vs Service Workers ​

Web Workers:

  • Run JS on a separate thread (parallel to main thread)
  • Can't access DOM
  • Communicate via postMessage / MessageChannel
  • Lifetime: tied to the page that created them
  • Use case: heavy computation (CSV parsing, data processing, encryption)
tsx
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.postMessage({ type: 'PARSE_CSV', data: csvString });
worker.onmessage = (e) => setRows(e.data.rows);

// worker.ts
self.onmessage = (e) => {
  if (e.data.type === 'PARSE_CSV') {
    const rows = parseCSV(e.data.data); // heavy work, off main thread
    self.postMessage({ rows });
  }
};

Service Workers:

  • Run as a proxy between browser and network
  • Intercept ALL network requests from the page
  • Lifetime: independent of the page (persists across page loads)
  • Can't access DOM
  • Use case: offline support, push notifications, background sync, caching
tsx
// Register
navigator.serviceWorker.register('/sw.js');

// sw.js — intercept requests
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request);
    })
  );
});

Key differences:

Web WorkerService Worker
PurposeHeavy computationNetwork proxy / caching
LifetimePage sessionIndependent (persistent)
DOM accessNoNo
Network interceptNoYes
Multiple per pageYesOne per scope

6. SharedArrayBuffer ​

What: A memory buffer that can be shared between the main thread and Web Workers without copying. Enables true shared memory parallelism.

tsx
// Main thread
const buffer = new SharedArrayBuffer(1024); // 1KB shared memory
const view = new Int32Array(buffer);
view[0] = 42;

worker.postMessage(buffer); // NOT copied, shared

// Worker
self.onmessage = (e) => {
  const view = new Int32Array(e.data);
  console.log(view[0]); // 42 — reading same memory
  Atomics.add(view, 0, 1); // thread-safe increment
  // view[0] is now 43 in both main thread and worker
};

Requires COOP/COEP headers (security):

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Atomics: Since memory is shared, you need atomic operations to prevent races:

  • Atomics.add(), Atomics.sub() — thread-safe math
  • Atomics.wait(), Atomics.notify() — mutex-like synchronization
  • Atomics.compareExchange() — CAS (compare-and-swap)

7. Transferable Objects ​

What: Move ownership of certain objects to a worker instead of copying them. The original thread loses access (transferring, not sharing).

tsx
// Without transfer: copies the buffer (slow for large data)
worker.postMessage(largeBuffer);

// With transfer: moves the buffer (instant, zero-copy)
worker.postMessage(largeBuffer, [largeBuffer]);
// largeBuffer.byteLength is now 0 in the main thread — ownership transferred

Transferable types:

  • ArrayBuffer
  • MessagePort
  • ImageBitmap
  • OffscreenCanvas
  • ReadableStream, WritableStream, TransformStream

Use case: Sending large image data to a worker for processing:

tsx
const imageData = canvas.getContext('2d').getImageData(0, 0, w, h);
worker.postMessage(imageData, [imageData.data.buffer]); // transfer, not copy

8. OffscreenCanvas ​

What: A canvas that can be rendered to in a Web Worker, keeping the main thread free for UI work.

tsx
// Main thread — transfer canvas to worker
const canvas = document.getElementById('chart') as HTMLCanvasElement;
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker('chart-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);

// chart-worker.js — render off main thread
self.onmessage = (e) => {
  const canvas = e.data.canvas;
  const ctx = canvas.getContext('2d');

  function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // Complex chart rendering — doesn't block main thread
    drawChart(ctx, data);
    requestAnimationFrame(draw);
  }
  draw();
};

Use case: Real-time charts on a dashboard (e.g., transaction volume chart) that update frequently without janking the UI.


9. WebAssembly Integration ​

What: A binary instruction format that runs near-native speed in the browser. Compiled from C, C++, Rust, Go. Used for performance-critical code.

tsx
// Load and instantiate WASM module
const response = await fetch('/crypto.wasm');
const wasmModule = await WebAssembly.instantiateStreaming(response, {
  env: { /* imported functions */ }
});

// Call exported function
const result = wasmModule.instance.exports.encryptData(data);

Use cases in frontend:

  • Heavy computation (image processing, video encoding)
  • Cryptography (client-side encryption for fintech)
  • PDF generation/parsing
  • Data compression
  • Game engines

When NOT to use WASM:

  • Simple DOM manipulation (WASM can't access DOM directly)
  • I/O bound work (fetch, reading files) — JS is fine
  • Small computations — WASM has call overhead

10. PerformanceObserver API ​

What: Asynchronously observe performance metrics (long tasks, layout shifts, largest paints, resource timing) without polling.

tsx
// Observe Long Tasks (>50ms)
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`Long task: ${entry.duration}ms`, entry);
    reportToAnalytics({ type: 'long-task', duration: entry.duration });
  }
});
longTaskObserver.observe({ type: 'longtask', buffered: true });

// Observe Largest Contentful Paint
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1]; // latest LCP candidate
  console.log('LCP:', lastEntry.startTime, lastEntry.element);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

// Observe Layout Shifts (CLS)
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) { // ignore user-initiated shifts
      clsValue += entry.value;
    }
  }
});
clsObserver.observe({ type: 'layout-shift', buffered: true });

11. Long Tasks API ​

What: Flags any task (JS execution) that blocks the main thread for >50ms. At 60fps, each frame budget is 16ms. A 50ms task means at least 3 dropped frames.

tsx
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.duration > 50ms
    // entry.name — task type
    // entry.attribution — what caused it (script URL, function name)
    console.warn(`Long task: ${entry.duration}ms`, entry.attribution);
  }
});
observer.observe({ type: 'longtask', buffered: true });

Common causes of long tasks:

  • Large React re-renders
  • JSON.parse on huge responses
  • Third-party scripts (analytics, ads)
  • Complex CSS selectors causing slow style recalculation

Fix: Break long tasks into smaller ones using scheduler.yield() or setTimeout(fn, 0) between chunks.


12. Pointer Events ​

What: A unified event model that handles mouse, touch, and pen input with a single set of events. Replaces separate mouse + touch event handling.

tsx
// Instead of:
element.addEventListener('mousedown', handler);
element.addEventListener('touchstart', handler);

// Use:
element.addEventListener('pointerdown', handler);
element.addEventListener('pointermove', handler);
element.addEventListener('pointerup', handler);

Pointer event properties:

tsx
function handlePointer(e: PointerEvent) {
  e.pointerId;    // unique ID for each pointer (finger, pen, mouse)
  e.pointerType;  // 'mouse' | 'touch' | 'pen'
  e.pressure;     // 0-1 (pen pressure, touch force)
  e.tiltX;        // pen tilt
  e.width;        // contact geometry (touch area)
  e.height;
  e.isPrimary;    // is this the primary pointer?
}

Pointer capture: Lock all pointer events to one element (useful for drag):

tsx
element.setPointerCapture(e.pointerId);
// Now ALL pointer events go to this element, even if pointer moves outside
// Useful for: drag handles, sliders, resize handles

React:

tsx
<div
  onPointerDown={handleStart}
  onPointerMove={handleMove}
  onPointerUp={handleEnd}
  onPointerCancel={handleCancel} // handle interrupted gestures
/>

Frontend interview preparation reference.