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.
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
scrollevent +getBoundingClientRect()(which causes layout thrashing)
React hook pattern:
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.
// 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
requestAnimationFrameto defer the write - Use CSS containment (
contain: size) to prevent cascading resizes
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.
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: trueondocument.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.
// 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:
| Feature | localStorage | IndexedDB |
|---|---|---|
| Storage limit | ~5MB | ~50MB-unlimited (with permission) |
| Data types | Strings only | Objects, blobs, files, arrays |
| Async | No (blocks main thread) | Yes (non-blocking) |
| Indexes | No | Yes (queryable) |
| Transactions | No | Yes (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)
// 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
// 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 Worker | Service Worker | |
|---|---|---|
| Purpose | Heavy computation | Network proxy / caching |
| Lifetime | Page session | Independent (persistent) |
| DOM access | No | No |
| Network intercept | No | Yes |
| Multiple per page | Yes | One 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.
// 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-corpAtomics: Since memory is shared, you need atomic operations to prevent races:
Atomics.add(),Atomics.sub()— thread-safe mathAtomics.wait(),Atomics.notify()— mutex-like synchronizationAtomics.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).
// 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 transferredTransferable types:
ArrayBufferMessagePortImageBitmapOffscreenCanvasReadableStream,WritableStream,TransformStream
Use case: Sending large image data to a worker for processing:
const imageData = canvas.getContext('2d').getImageData(0, 0, w, h);
worker.postMessage(imageData, [imageData.data.buffer]); // transfer, not copy8. OffscreenCanvas ​
What: A canvas that can be rendered to in a Web Worker, keeping the main thread free for UI work.
// 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.
// 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.
// 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.
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.
// 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:
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):
element.setPointerCapture(e.pointerId);
// Now ALL pointer events go to this element, even if pointer moves outside
// Useful for: drag handles, sliders, resize handlesReact:
<div
onPointerDown={handleStart}
onPointerMove={handleMove}
onPointerUp={handleEnd}
onPointerCancel={handleCancel} // handle interrupted gestures
/>