Browser APIs like Fetch, Web Workers, and Service Workers are key for modern frontend interviews. Learn them with practical examples.
Picture a luxury hotel. You (JavaScript) are a guest. The hotel itself is the browser. The hotel has dozens of departments: concierge (fetch API), storage (localStorage, sessionStorage, IndexedDB), messaging service (postMessage, BroadcastChannel), security (CORS, CSP), background services (Service Workers, Web Workers), alarm system (notifications, permissions), and a fitness center with specialized equipment (Canvas, WebGL, WebAudio). You don't run these departments yourself — you request services from them. They operate on their own threads, their own rules, their own lifecycle. The key insight: browser APIs are not JavaScript — they're services provided by the browser runtime that JavaScript can call. JavaScript itself has no concept of "HTTP request" or "local storage" or "rendering a pixel" — those capabilities come entirely from the host environment. When you call fetch(), you're not running JavaScript that makes a network request — you're asking the browser's networking department to do it, and the browser hands you back a Promise so you can continue working while they handle it. Understanding this boundary — where JavaScript ends and browser APIs begin — is what makes debugging web applications accurate instead of guesswork.
// localStorage — synchronous, string-only, persists until cleared, ~5MB
localStorage.setItem('theme', 'dark')
localStorage.getItem('theme') // 'dark'
localStorage.removeItem('theme')
localStorage.clear() // removes all keys for this origin
// Always serialize objects:
const prefs = { theme: 'dark', fontSize: 16 }
localStorage.setItem('prefs', JSON.stringify(prefs))
const loaded = JSON.parse(localStorage.getItem('prefs'))
// localStorage is synchronous — it BLOCKS the main thread
// Avoid large objects or frequent writes in performance-sensitive code
// sessionStorage — same API as localStorage, but cleared when tab closes
// Isolated per tab — two tabs of the same site don't share sessionStorage
sessionStorage.setItem('draftId', '123')
// IndexedDB — async, structured data, large storage (hundreds of MB+)
// Use for: offline data, large datasets, complex querying
// Raw API is verbose — use the 'idb' library in production
import { openDB } from 'idb'
const db = await openDB('myapp', 1, {
upgrade(db) {
db.createObjectStore('items', { keyPath: 'id' })
}
})
await db.put('items', { id: 1, name: 'Widget', qty: 5 })
const item = await db.get('items', 1)
const all = await db.getAll('items')
// fetch() resolves when HEADERS arrive, not when body is complete
// HTTP errors (404, 500) do NOT reject — only network failure rejects
const res = await fetch('/api/users')
// Always check res.ok — otherwise 404s look like success
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
const data = await res.json() // second Promise — parses body
// Full request options:
const res = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ name: 'Alice' }),
credentials: 'include', // send cookies cross-origin
cache: 'no-store', // bypass cache
signal: controller.signal, // AbortController support
})
// AbortController — cancel in-flight requests
const controller = new AbortController()
// Cancel when React component unmounts:
useEffect(() => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => {
if (err.name === 'AbortError') return // expected — not a real error
throw err
})
return () => controller.abort() // cleanup on unmount
}, [])
// Web Workers run in a separate thread — no access to DOM, window, or document
// Use for: CPU-heavy computation that would freeze the UI
// main.js
const worker = new Worker('./compute.worker.js')
worker.postMessage({ data: largeArray, operation: 'sort' })
worker.onmessage = (e) => {
console.log('Result:', e.data.result)
}
worker.onerror = (e) => console.error(e.message)
// compute.worker.js
self.onmessage = (e) => {
const { data, operation } = e.data
if (operation === 'sort') {
const result = expensiveSort(data) // runs on worker thread — UI stays responsive
self.postMessage({ result })
}
}
// Transferable objects — zero-copy transfer of ArrayBuffers between threads
const buffer = new ArrayBuffer(1024 * 1024) // 1MB
// Transfer ownership — buffer becomes unusable in main thread after this
worker.postMessage({ buffer }, [buffer])
// SharedArrayBuffer — truly shared memory between threads (requires COOP/COEP headers)
const shared = new SharedArrayBuffer(1024)
const view = new Int32Array(shared)
Atomics.add(view, 0, 1) // atomic operation — safe for concurrent access
// Service Worker sits between your app and the network
// Intercepts all fetch requests — can serve from cache, modify, or pass through
// Registration (main thread):
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.register('/sw.js')
console.log('SW registered, scope:', reg.scope)
}
// sw.js — runs in its own context, separate from any page
const CACHE_NAME = 'v1'
const PRECACHE = ['/index.html', '/app.js', '/styles.css']
// Install — cache essential assets
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE))
.then(() => self.skipWaiting())
)
})
// Activate — clean up old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
))
.then(() => self.clients.claim())
)
})
// Fetch — serve from cache, fall back to network (cache-first strategy)
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request)
})
)
})
// IntersectionObserver — element visibility without scroll listeners
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage(entry.target) // lazy load when visible
observer.unobserve(entry.target) // stop watching after load
}
})
}, {
root: null, // viewport
rootMargin: '200px', // start loading 200px before element enters viewport
threshold: 0.1 // fire when 10% of element is visible
})
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img))
// MutationObserver — watch DOM changes
const mutObs = new MutationObserver((mutations) => {
mutations.forEach(m => {
if (m.type === 'childList') {
m.addedNodes.forEach(node => initializeComponent(node))
}
if (m.type === 'attributes') {
console.log(`${m.attributeName} changed to ${m.target.getAttribute(m.attributeName)}`)
}
})
})
mutObs.observe(document.body, {
childList: true, // direct children added/removed
subtree: true, // all descendants
attributes: true, // attribute changes
})
mutObs.disconnect() // stop observing
// ResizeObserver — element size changes without window resize events
const resObs = new ResizeObserver(entries => {
entries.forEach(({ contentRect, target }) => {
target.style.fontSize = contentRect.width > 600 ? '18px' : '14px'
})
})
resObs.observe(document.querySelector('.responsive-text'))
// URL API — parse and manipulate URLs
const url = new URL('https://example.com/search?q=hello&page=2')
url.hostname // 'example.com'
url.pathname // '/search'
url.searchParams.get('q') // 'hello'
url.searchParams.set('page', '3')
url.toString() // 'https://example.com/search?q=hello&page=3'
// URLSearchParams — standalone query string manipulation
const params = new URLSearchParams(window.location.search)
params.append('filter', 'active')
window.history.replaceState({}, '', `?${params}`)
// History API — SPA navigation without page reload
window.history.pushState({ page: 2 }, '', '/page/2') // add history entry
window.history.replaceState({ page: 2 }, '', '/page/2') // replace current entry
window.history.back() // go back
window.history.go(-2) // go back 2 entries
// popstate — fires on back/forward navigation
window.addEventListener('popstate', (e) => {
console.log('navigated to state:', e.state)
renderPage(e.state)
})
// Clipboard API — async, requires user gesture or permission
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text)
showToast('Copied!')
} catch (err) {
// Fallback for older browsers or permission denied
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy') // deprecated but widely supported fallback
document.body.removeChild(textarea)
}
}
const text = await navigator.clipboard.readText() // requires explicit permission
// Permissions API — check status before requesting
const status = await navigator.permissions.query({ name: 'clipboard-read' })
// status.state: 'granted' | 'denied' | 'prompt'
// Notifications
const permission = await Notification.requestPermission()
if (permission === 'granted') {
new Notification('New message', {
body: 'Alice sent you a message',
icon: '/icon.png',
badge: '/badge.png',
})
}
Many devs think fetch() rejects on HTTP errors like 404 or 500 — but actually fetch() only rejects on network-level failures (no internet, DNS failure, CORS block). A 404 or 500 response resolves successfully — you must explicitly check response.ok or response.status to detect HTTP errors. This is the most common fetch bug in production code and causes silent failures where error states are never triggered.
Many devs think localStorage is a good place for sensitive data because it persists between sessions — but actually localStorage is accessible to any JavaScript running on that origin, making it vulnerable to XSS attacks. Sensitive data like authentication tokens should be stored in httpOnly cookies (inaccessible to JavaScript entirely) rather than localStorage or sessionStorage.
Many devs think Web Workers can access the DOM to make UI changes — but actually Web Workers run in a completely isolated thread with no access to the DOM, window, document, or any browser rendering APIs. They can only communicate with the main thread via postMessage. All DOM manipulation must happen on the main thread; Workers are for pure computation.
Many devs think Service Workers intercept all network requests from a page immediately after registration — but actually a Service Worker only controls pages loaded after it has been activated. The first page load that registers the SW is not controlled by it — the SW controls subsequent navigations. Calling self.skipWaiting() and self.clients.claim() in the SW overrides this behavior for immediate control, but this has tradeoffs for consistency.
Many devs think the three Observer APIs (Intersection, Mutation, Resize) are just convenience wrappers around scroll/resize/MutationEvent listeners — but actually they are fundamentally different: they're asynchronous, batched, and delivered outside the main synchronous execution flow in a way that avoids forced reflows. The old approach of attaching a scroll listener and reading offsetTop forced synchronous layout calculations; IntersectionObserver does not — it's architecturally different, not just syntactically different.
Next.js's Static Site Generation and Incremental Static Regeneration work by using the fetch() API with specific cache options — fetch(url, { next: { revalidate: 60 } }) in Next.js 13+ is the standard way to control caching behavior. Understanding the native fetch API and its cache option ('force-cache', 'no-store', 'no-cache') is prerequisite to understanding why ISR works the way it does and why revalidation timing sometimes surprises developers.
Vite's development server uses Service Workers for HMR (Hot Module Replacement) in its module federation feature, and production PWA builds use Service Workers for offline support through the vite-plugin-pwa package. The cache-first vs network-first vs stale-while-revalidate strategies are not abstract concepts — they map directly to specific fetch event handler patterns in the Service Worker.
Figma's performance on complex documents depends on Web Workers for computation — layout algorithms, constraint solving, and data transformation all happen on worker threads while the main thread handles rendering. Without Web Workers, moving a complex component group in Figma would freeze the UI. Understanding postMessage and Transferable objects is fundamental to building any high-performance web application with complex data processing.
Google Analytics and other tracking libraries use IntersectionObserver for impression tracking — recording when an ad or content element is actually visible to the user, not just present in the DOM. The threshold option (fire when 50% of the element is visible for at least 1 second) lets marketers define "seen" precisely. Before IntersectionObserver, this required scroll event listeners with getBoundingClientRect calls — a layout-thrashing implementation that degraded page performance.
The Web Share API (navigator.share()) is used by PWAs to integrate with the native OS share sheet — when a user on mobile clicks "Share" in a PWA, the same share dialog that appears in native apps appears, with the app's URL and title pre-filled. This is the boundary where browser APIs provide native-level capabilities through a JavaScript interface, and it only works in secure contexts (HTTPS) triggered by a user gesture.
No questions tagged to this topic yet.
Tag questions in Admin → Questions by setting the "Topic Page" field to javascript-browser-apis-interview-questions.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.