Browser APIs
Browser APIs
Modern browsers provide powerful APIs that enable rich, app-like experiences. Understanding these APIs helps you build performant, capable web applications.
Web Storage
Web Storage provides a way to store key-value pairs in the browser. Unlike cookies, data stored here isn't sent with every HTTP request, making it more efficient for client-side data persistence.
localStorage and sessionStorage
localStorage persists data indefinitely until explicitly cleared—even after closing the browser. It's ideal for:
- User preferences (theme, language)
- Authentication tokens (though HttpOnly cookies are safer)
- Cached data that should survive page refreshes
sessionStorage works identically but clears when the browser tab closes. Use it for:
- Temporary form data
- Page-specific state that shouldn't persist
- Multi-step wizard progress
Both have a ~5MB storage limit per origin and are synchronous (can block the main thread with large data).
// localStorage - persists across sessions
localStorage.setItem("user", JSON.stringify({ id: 1, name: "John" }));
const user = JSON.parse(localStorage.getItem("user"));
localStorage.removeItem("user");
localStorage.clear();
// sessionStorage - cleared when tab closes
sessionStorage.setItem("tempData", "value");
// Storage wrapper with expiry
class StorageWithExpiry {
constructor(storage = localStorage) {
this.storage = storage;
}
set(key, value, ttlMs) {
const item = {
value,
expiry: ttlMs ? Date.now() + ttlMs : null,
};
this.storage.setItem(key, JSON.stringify(item));
}
get(key) {
const itemStr = this.storage.getItem(key);
if (!itemStr) return null;
const item = JSON.parse(itemStr);
if (item.expiry && Date.now() > item.expiry) {
this.storage.removeItem(key);
return null;
}
return item.value;
}
remove(key) {
this.storage.removeItem(key);
}
}
const cache = new StorageWithExpiry();
cache.set("apiData", data, 60 * 60 * 1000); // 1 hour expiryStorage Events
When a user has multiple tabs open, you often need to keep them in sync. If a user logs out in one tab, all other tabs should reflect that immediately. The storage event fires when localStorage changes in another tab (not the current one), enabling cross-tab communication.
Common use cases:
- Logout synchronization across tabs
- Theme/preference updates
- Real-time data sync without WebSockets
- Shopping cart updates
// Listen for changes from other tabs
window.addEventListener("storage", (e) => {
console.log({
key: e.key,
oldValue: e.oldValue,
newValue: e.newValue,
url: e.url, // Which tab made the change
});
if (e.key === "user") {
// Sync user state across tabs
updateUserState(JSON.parse(e.newValue));
}
});
// Cross-tab communication
function broadcastLogout() {
localStorage.setItem("logout", Date.now().toString());
localStorage.removeItem("logout");
}IndexedDB
While localStorage is simple, it has significant limitations:
- Only stores strings (must JSON.stringify/parse objects)
- ~5MB limit
- Synchronous API blocks the main thread
- No indexing or querying capabilities
IndexedDB solves these problems by providing:
- Storage of any JavaScript value (including Blobs, Files)
- Much larger storage limits (often 50%+ of disk space)
- Asynchronous API that doesn't block UI
- Indexes for fast lookups on any property
- Transactions for data integrity
When to use IndexedDB:
- Offline-first applications (PWAs)
- Large datasets (thousands of records)
- Complex data relationships
- Storing files and binary data
- When you need to query data by multiple fields
The API is callback-based and complex, so here's a Promise-based wrapper that makes it easier to use:
class Database {
constructor(name, version = 1) {
this.name = name;
this.version = version;
this.db = null;
}
async open(upgradeCallback) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.name, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
upgradeCallback(db, event.oldVersion, event.newVersion);
};
});
}
async get(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async put(storeName, value) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.put(value);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getAll(storeName, query, count) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const request = store.getAll(query, count);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async delete(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
// Usage
const db = new Database("MyApp", 1);
await db.open((database, oldVersion) => {
if (oldVersion < 1) {
const store = database.createObjectStore("users", { keyPath: "id" });
store.createIndex("email", "email", { unique: true });
}
});
await db.put("users", { id: 1, name: "John", email: "john@example.com" });
const user = await db.get("users", 1);Web Workers
JavaScript is single-threaded—long-running computations block the UI, causing janky scrolling, unresponsive buttons, and frustrated users. If a function takes 500ms to execute, the browser literally freezes for that duration.
Web Workers solve this by running JavaScript in a background thread. The worker runs in parallel with the main thread, communicating via message passing. This keeps the UI responsive while heavy work happens in the background.
Ideal use cases for Web Workers:
- Image/video processing
- Large data transformations (sorting, filtering millions of rows)
- Complex calculations (cryptography, physics simulations)
- Parsing large JSON or CSV files
- Syntax highlighting for code editors
Limitations to be aware of:
- No DOM access (workers can't manipulate the page directly)
- No access to
window,document, or parent scope variables - Communication happens via structured cloning (copying data, not sharing)
- Each worker is a separate file (though inline workers work around this)
Basic Worker
Here's the fundamental pattern: the main thread creates a worker, sends data to it, and receives results back via messages.
// worker.js
self.onmessage = function (e) {
const { type, data } = e.data;
switch (type) {
case "PROCESS":
const result = heavyComputation(data);
self.postMessage({ type: "RESULT", result });
break;
}
};
function heavyComputation(data) {
// CPU-intensive work here
return data.map((item) => complexTransform(item));
}
// main.js
const worker = new Worker("worker.js");
worker.onmessage = (e) => {
const { type, result } = e.data;
if (type === "RESULT") {
displayResults(result);
}
};
worker.onerror = (error) => {
console.error("Worker error:", error);
};
// Send work to worker
worker.postMessage({ type: "PROCESS", data: largeDataset });
// Clean up
worker.terminate();Inline Worker (No Separate File)
Creating a separate file for each worker can be cumbersome, especially for simple tasks. You can create workers dynamically using Blob URLs. This pattern embeds the worker code directly in your main file, making it easier to bundle and deploy.
function createWorker(fn) {
const blob = new Blob(
[`self.onmessage = function(e) { (${fn.toString()})(e.data); }`],
{ type: "application/javascript" },
);
return new Worker(URL.createObjectURL(blob));
}
const worker = createWorker((data) => {
const result = data.numbers.reduce((a, b) => a + b, 0);
self.postMessage(result);
});
worker.onmessage = (e) => console.log("Sum:", e.data);
worker.postMessage({ numbers: [1, 2, 3, 4, 5] });Worker Pool
Creating a new worker for each task is expensive—workers take time to initialize and consume memory. A Worker Pool maintains a fixed number of reusable workers, distributing tasks among them and queuing excess work.
Why use a pool?
- Avoids worker creation overhead for repeated tasks
- Limits memory usage by capping concurrent workers
- Automatically distributes work evenly
- Provides a clean Promise-based API
The pool size typically matches navigator.hardwareConcurrency (number of CPU cores) for optimal parallelism.
class WorkerPool {
constructor(workerScript, size = navigator.hardwareConcurrency || 4) {
this.workers = [];
this.queue = [];
this.activeJobs = new Map();
for (let i = 0; i < size; i++) {
const worker = new Worker(workerScript);
worker.onmessage = (e) => this.handleMessage(worker, e);
worker.onerror = (e) => this.handleError(worker, e);
this.workers.push({ worker, busy: false });
}
}
run(data) {
return new Promise((resolve, reject) => {
const job = { data, resolve, reject };
const available = this.workers.find((w) => !w.busy);
if (available) {
this.dispatch(available, job);
} else {
this.queue.push(job);
}
});
}
dispatch(workerInfo, job) {
workerInfo.busy = true;
this.activeJobs.set(workerInfo.worker, job);
workerInfo.worker.postMessage(job.data);
}
handleMessage(worker, e) {
const job = this.activeJobs.get(worker);
if (job) {
job.resolve(e.data);
this.activeJobs.delete(worker);
}
const workerInfo = this.workers.find((w) => w.worker === worker);
if (this.queue.length > 0) {
this.dispatch(workerInfo, this.queue.shift());
} else {
workerInfo.busy = false;
}
}
handleError(worker, error) {
const job = this.activeJobs.get(worker);
if (job) {
job.reject(error);
this.activeJobs.delete(worker);
}
}
terminate() {
this.workers.forEach((w) => w.worker.terminate());
}
}
// Usage
const pool = new WorkerPool("processor.js", 4);
const results = await Promise.all([
pool.run({ task: "process", data: chunk1 }),
pool.run({ task: "process", data: chunk2 }),
pool.run({ task: "process", data: chunk3 }),
pool.run({ task: "process", data: chunk4 }),
]);Service Workers
Service Workers are a special type of Web Worker that acts as a proxy between your app and the network. They intercept every network request, allowing you to:
- Cache responses for offline access
- Serve cached content when the network is slow or unavailable
- Update content in the background without user interaction
- Push notifications even when the app isn't open
Key characteristics:
- Lives beyond page lifetime (persists even after tab closes)
- Only works on HTTPS (or localhost for development)
- Has its own lifecycle (install → activate → fetch)
- Cannot access DOM (uses
postMessageto communicate)
The Service Worker lifecycle:
- Register: Browser downloads and parses the SW file
- Install: SW caches essential assets
- Activate: SW takes control (old SWs are removed)
- Fetch: SW intercepts network requests
// Registering a service worker
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log("SW registered:", registration.scope);
})
.catch((error) => {
console.error("SW registration failed:", error);
});
}
// sw.js
const CACHE_NAME = "app-v1";
const ASSETS = ["/", "/index.html", "/styles.css", "/app.js", "/offline.html"];
// Install - cache assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(ASSETS))
.then(() => self.skipWaiting()),
);
});
// Activate - clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
// Fetch - serve from cache or network
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached;
}
return fetch(event.request)
.then((response) => {
// Cache successful responses
if (response.ok) {
const clone = response.clone();
caches
.open(CACHE_NAME)
.then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => {
// Offline fallback
if (event.request.mode === "navigate") {
return caches.match("/offline.html");
}
});
}),
);
});Broadcast Channel
While the storage event only fires for localStorage changes, Broadcast Channel provides a dedicated API for cross-tab/window communication. It's simpler and more explicit than the localStorage hack.
Why use Broadcast Channel instead of storage events?
- Works with any data type (not just strings)
- Fires in real-time without storage side effects
- Cleaner API with explicit channel names
- Also works between iframes and workers
Common use cases:
- Synchronized logout across tabs
- Real-time preference updates
- Collaborative features (multiple tabs editing same document)
- Sharing state without server round-trips
// Create channel
const channel = new BroadcastChannel("app-messages");
// Send message
channel.postMessage({
type: "USER_LOGGED_OUT",
timestamp: Date.now(),
});
// Receive messages
channel.onmessage = (event) => {
const { type, ...data } = event.data;
switch (type) {
case "USER_LOGGED_OUT":
clearLocalSession();
redirectToLogin();
break;
case "THEME_CHANGED":
updateTheme(data.theme);
break;
}
};
// Close when done
channel.close();Clipboard API
The Clipboard API provides secure, asynchronous access to read and write clipboard content. It replaced the synchronous document.execCommand('copy') which had security concerns and inconsistent behavior.
Why the modern Clipboard API?
- Asynchronous (returns Promises)
- Requires user permission for reading
- Works in secure contexts (HTTPS) only
- Can handle rich content (images, HTML)
- Better security model
Permission notes:
- Writing (copy) usually works without explicit permission if triggered by user action
- Reading (paste) always requires permission
- Browsers show permission prompts automatically
// Write to clipboard
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
showNotification("Copied!");
} catch (err) {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
// Read from clipboard
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();
return text;
} catch (err) {
console.error("Failed to read clipboard:", err);
}
}
// Copy rich content
async function copyRichContent() {
const blob = new Blob(["<b>Rich</b> content"], { type: "text/html" });
const item = new ClipboardItem({ "text/html": blob });
await navigator.clipboard.write([item]);
}Geolocation
The Geolocation API lets you access the user's geographical location (with their permission). It uses various sources—GPS, Wi-Fi positioning, IP geolocation—to determine position.
Common use cases:
- Store locators ("Find stores near me")
- Delivery apps (tracking, estimated arrival)
- Weather apps (local forecasts)
- Maps and navigation
- Location-based content personalization
Privacy considerations:
- Always requires explicit user permission
- Only works in secure contexts (HTTPS)
- Users can deny or revoke permission anytime
- Consider providing manual location entry as fallback
Accuracy options:
enableHighAccuracy: trueuses GPS (slower, more battery, more precise)enableHighAccuracy: falseuses network-based location (faster, less precise)
function getCurrentPosition(options = {}) {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error("Geolocation not supported"));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp,
});
},
(error) => {
reject(error);
},
{
enableHighAccuracy: options.highAccuracy || false,
timeout: options.timeout || 5000,
maximumAge: options.maxAge || 0,
},
);
});
}
// Watch position
function watchPosition(callback, errorCallback) {
const id = navigator.geolocation.watchPosition(callback, errorCallback, {
enableHighAccuracy: true,
});
// Return cleanup function
return () => navigator.geolocation.clearWatch(id);
}
// Usage
const stopWatching = watchPosition(
(position) => updateMap(position.coords),
(error) => console.error(error),
);
// Later: stopWatching();Notifications API
The Notifications API displays system-level notifications outside the browser window. Unlike in-app toasts, these appear even when the user is in another application.
When to use notifications:
- New messages in chat applications
- Background task completion
- Important alerts (price drops, reminders)
- Real-time updates (breaking news)
Best practices:
- Ask for permission at the right moment (not immediately on page load)
- Provide value—don't spam users with trivial notifications
- Use
tagto replace similar notifications (avoid notification pile-up) - Include actions users can take directly from the notification
Permission states:
default: User hasn't decided yetgranted: User allowed notificationsdenied: User blocked notifications (can't ask again)
async function requestNotificationPermission() {
if (!("Notification" in window)) {
console.log("Notifications not supported");
return false;
}
if (Notification.permission === "granted") {
return true;
}
if (Notification.permission !== "denied") {
const permission = await Notification.requestPermission();
return permission === "granted";
}
return false;
}
async function showNotification(title, options = {}) {
const permitted = await requestNotificationPermission();
if (!permitted) return;
const notification = new Notification(title, {
body: options.body,
icon: options.icon || "/icon.png",
badge: options.badge,
tag: options.tag, // Replace existing with same tag
requireInteraction: options.requireInteraction || false,
data: options.data,
});
notification.onclick = (event) => {
event.preventDefault();
window.focus();
options.onClick?.(event);
notification.close();
};
if (options.autoClose) {
setTimeout(() => notification.close(), options.autoClose);
}
return notification;
}History API
The History API enables client-side navigation without full page reloads—the foundation of single-page applications (SPAs). It lets you change the URL, maintain browser history (back/forward buttons work), and store state for each history entry.
Why this matters:
- Better UX: No page reload flicker, preserved scroll position
- Shareable URLs: Deep linking to specific app states
- Browser navigation: Back/forward buttons work correctly
- SEO friendly: When combined with SSR, crawlers see proper URLs
Key methods:
pushState(): Add new entry to history stack (navigating forward)replaceState(): Modify current entry (fixing typos in URL, filters)popstateevent: Fires when user clicks back/forward
Important: pushState changes the URL but doesn't trigger any navigation—your code must handle rendering the new state.
// Push new state
history.pushState(
{ page: "products", id: 123 }, // State object
"", // Title (ignored by most browsers)
"/products/123", // URL
);
// Replace current state
history.replaceState(
{ page: "products", filter: "active" },
"",
"/products?filter=active",
);
// Listen for back/forward
window.addEventListener("popstate", (event) => {
if (event.state) {
// Navigate based on state
navigateTo(event.state.page, event.state);
}
});
// Simple router
class Router {
constructor() {
this.routes = new Map();
window.addEventListener("popstate", () => {
this.handleRoute(location.pathname);
});
}
register(path, handler) {
this.routes.set(path, handler);
}
navigate(path, state = {}) {
history.pushState(state, "", path);
this.handleRoute(path);
}
handleRoute(path) {
// Check for exact match
if (this.routes.has(path)) {
this.routes.get(path)();
return;
}
// Check for pattern match
for (const [pattern, handler] of this.routes) {
const match = this.matchPattern(pattern, path);
if (match) {
handler(match.params);
return;
}
}
// 404
this.routes.get("*")?.();
}
matchPattern(pattern, path) {
const patternParts = pattern.split("/");
const pathParts = path.split("/");
if (patternParts.length !== pathParts.length) return null;
const params = {};
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(":")) {
params[patternParts[i].slice(1)] = pathParts[i];
} else if (patternParts[i] !== pathParts[i]) {
return null;
}
}
return { params };
}
}Page Visibility API
The Page Visibility API tells you when a page is visible (user is looking at it) or hidden (tab is in background, browser is minimized). This is crucial for optimizing performance and user experience.
Why track visibility?
- Save resources: Pause animations, videos, and polling when tab is hidden
- Accurate analytics: Know actual time spent viewing content
- Better UX: Resume smoothly when user returns
- Battery life: Stop CPU-intensive work when not needed
Visibility states:
visible: Page is in the foreground tab of a non-minimized windowhidden: Page is not visible (background tab, minimized window, lock screen)
Common applications:
- Pause video/audio playback
- Stop real-time data polling
- Pause game loops
- Defer non-critical updates
- Track engagement metrics accurately
// Detect when page is visible/hidden
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
// Page is hidden - pause expensive operations
pauseVideo();
pauseAnimations();
disconnectWebSocket();
} else {
// Page is visible again
resumeVideo();
resumeAnimations();
reconnectWebSocket();
}
});
// Check current state
if (document.visibilityState === "visible") {
startPolling();
}
// Use for analytics
let startTime;
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
const duration = Date.now() - startTime;
analytics.track("page_visible_duration", { duration });
} else {
startTime = Date.now();
}
});Network Information API
if ("connection" in navigator) {
const connection = navigator.connection;
console.log({
effectiveType: connection.effectiveType, // '4g', '3g', '2g', 'slow-2g'
downlink: connection.downlink, // Mbps
rtt: connection.rtt, // Round-trip time in ms
saveData: connection.saveData, // Data saver enabled
});
// Adapt to network conditions
if (connection.saveData || connection.effectiveType === "2g") {
loadLowQualityImages();
disableAutoplay();
}
// Listen for changes
connection.addEventListener("change", () => {
console.log("Connection changed:", connection.effectiveType);
adaptToNetwork(connection);
});
}Battery Status API
if ("getBattery" in navigator) {
navigator.getBattery().then((battery) => {
console.log({
level: battery.level, // 0 to 1
charging: battery.charging,
chargingTime: battery.chargingTime,
dischargingTime: battery.dischargingTime,
});
// Adapt to battery level
if (battery.level < 0.2 && !battery.charging) {
enableLowPowerMode();
}
battery.addEventListener("levelchange", () => {
updateBatteryUI(battery.level);
});
battery.addEventListener("chargingchange", () => {
if (battery.charging) {
disableLowPowerMode();
}
});
});
}Summary
| API | Purpose | Common Use Cases |
|---|---|---|
| localStorage | Persistent key-value | User preferences, tokens |
| sessionStorage | Session key-value | Form drafts, temp data |
| IndexedDB | Client-side database | Offline data, caching |
| Web Workers | Background threads | Heavy computation |
| Service Workers | Request proxy | Offline support, caching |
| Broadcast Channel | Cross-tab messaging | Session sync |
| Clipboard | Copy/paste | Share, export |
| Geolocation | User location | Maps, local content |
| Notifications | System notifications | Alerts, updates |
| History | URL management | SPA routing |
| Visibility | Tab focus state | Resource management |
Key Principles:
- Check for API support before using
- Handle permissions gracefully
- Provide fallbacks for unsupported browsers
- Clean up resources when done
- Consider privacy implications