Skip to main content
JavaScript · lesson 19 of 24
JavaScript intermediate Lesson 19 of 24

Browser Web APIs in JavaScript

A practical guide to the most useful browser Web APIs — Fetch, localStorage, Intersection Observer, Web Workers, WebSockets, and Geolocation.

By SkillByExample · Updated

Web APIs are the objects a browser provides on top of the language: networking, storage, background threads, sockets, device access. You can’t run most of them in plain Node, so the examples here run in two places, and every output block is a real run. fetch and WebSocket are built into Node 22 with the same API as browsers, so those run in Node. The rest run in headless Chromium driven by Playwright (npm install playwright, then npx playwright install chromium). In those scripts, page.route serves a blank page at https://app.test/ so the code runs on a normal, secure (HTTPS) origin — several APIs refuse to work anywhere else, and everything inside page.evaluate(...) is ordinary browser code.

fetch: a Wrapper That Handles Errors

fetch resolves even for 404 and 500 responses, and response.json() throws on an empty body. Wrap it once, so every call site gets the checks.

import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.url === "/users" && req.method === "POST") res.writeHead(201).end('{"id": 7, "name": "Alice"}');
  else if (req.url === "/users/alice") res.writeHead(409).end('{"error": "exists"}');
  else if (req.url === "/session") res.writeHead(204).end();
  else res.writeHead(404).end("not found");
}).listen(0);
const base = `http://localhost:${server.address().port}`;

async function apiFetch(path, options = {}) {
  const response = await fetch(base + path, {
    ...options,
    headers: { "Content-Type": "application/json", ...options.headers },
  });
  if (!response.ok) {
    const error = new Error(`HTTP ${response.status} ${response.statusText} for ${path}`);
    error.status = response.status;
    throw error;
  }
  return response.status === 204 ? null : response.json();
}

console.log(await apiFetch("/users", { method: "POST", body: JSON.stringify({ name: "Alice" }) }));
console.log(await apiFetch("/session", { method: "DELETE" }));
for (const path of ["/users/alice", "/nope"]) {
  try { await apiFetch(path); } catch (err) { console.log(err.status === 409 ? "already exists ->" : "failed ->", err.message); }
}
server.close();
$ node fetch.mjs
{ id: 7, name: 'Alice' }
null
already exists -> HTTP 409 Conflict for /users/alice
failed -> HTTP 404 Not Found for /nope

Both error responses became real exceptions carrying status, so the caller can branch on 409 (“already exists”) separately from other failures. The 204 returned null instead of crashing in response.json(). The wrapper also merges headers properly: a plain { ...defaults, ...options } would let a caller’s headers object replace the defaults entirely.

Cancelling a Request: AbortController

An AbortSignal cancels a request that is taking too long or is no longer needed — for example, the previous search when the user types another letter.

import http from "node:http";

const server = http.createServer((req, res) => {
  const delay = Number(new URL(req.url, "http://x").searchParams.get("delay"));
  setTimeout(() => res.end(`answered after ${delay} ms`), delay);
}).listen(0);
const url = (delay) => `http://localhost:${server.address().port}/?delay=${delay}`;

for (const delay of [50, 500]) {
  try {
    const res = await fetch(url(delay), { signal: AbortSignal.timeout(200) });
    console.log(await res.text());
  } catch (err) {
    console.log(`${delay} ms request: ${err.name} - ${err.message}`);
  }
}

let controller;
async function search(q) {
  controller?.abort();
  controller = new AbortController();
  try {
    const res = await fetch(url(100), { signal: controller.signal });
    console.log(`"${q}" ->`, await res.text());
  } catch (err) {
    if (err.name === "AbortError") console.log(`"${q}" -> cancelled`);
    else throw err;
  }
}
await Promise.all([search("c"), search("ca"), search("cat")]);
server.closeAllConnections();
server.close();
$ node abort.mjs
answered after 50 ms
500 ms request: TimeoutError - The operation was aborted due to timeout
"c" -> cancelled
"ca" -> cancelled
"cat" -> answered after 100 ms

AbortSignal.timeout(200) rejected the slow request with a TimeoutError. In the search, each new keystroke aborted the previous request (AbortError), so only "cat"’s answer was used. Without this, the slower response to an early keystroke can arrive last and overwrite the right results. Check err.name so aborts aren’t reported as failures.

localStorage

localStorage stores strings per origin and survives reloads. Store anything else as JSON, and expect failures.

import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.route("https://app.test/**", (route) => route.fulfill({ body: "<!doctype html>", contentType: "text/html" }));
await page.goto("https://app.test/");

console.log(await page.evaluate(() => {
  const out = [];
  localStorage.setItem("count", 5);
  out.push(`raw count: ${JSON.stringify(localStorage.getItem("count"))}`);
  localStorage.setItem("user", { name: "Alice" });
  out.push(`object stored without JSON: ${localStorage.getItem("user")}`);
  localStorage.setItem("user", JSON.stringify({ name: "Alice", theme: "dark" }));
  out.push(`with JSON: ${JSON.parse(localStorage.getItem("user")).theme}`);
  out.push(`missing key: ${localStorage.getItem("nope")}`);
  try { localStorage.setItem("big", "x".repeat(6 * 1024 * 1024)); }
  catch (err) { out.push(`6 MB value: ${err.name}`); }
  return out.join("\n");
}));

await page.reload();
console.log("after reload:", await page.evaluate(() => Object.keys(localStorage).sort().join(", ")));
await browser.close();
$ node storage.mjs
raw count: "5"
object stored without JSON: [object Object]
with JSON: dark
missing key: null
6 MB value: QuotaExceededError
after reload: count, user

setItem converts its value to a string, so the number came back as "5" and the object as the useless "[object Object]". A missing key is null. Chromium allows roughly 5 MB per origin, so a 6 MB value threw QuotaExceededError — wrap writes in try/catch. After a reload the data was still there. sessionStorage has the same API but lasts only for the tab.

IntersectionObserver

Instead of listening to every scroll event and measuring elements, IntersectionObserver calls you back when elements enter or leave the viewport — the basis of lazy loading and infinite scroll.

import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 800, height: 600 } });
await page.route("https://app.test/**", (route) => route.fulfill({ contentType: "text/html",
  body: [1, 2, 3, 4, 5].map((n) => `<div id="s${n}" style="height:500px">section ${n}</div>`).join("") }));
await page.goto("https://app.test/");

await page.evaluate(() => {
  window.log = [];
  const observer = new IntersectionObserver((entries, obs) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      window.log.push(`${entry.target.id} visible`);
      obs.unobserve(entry.target);
    }
  }, { rootMargin: "200px" });
  document.querySelectorAll("div").forEach((el) => observer.observe(el));
});

for (const y of [0, 700, 1400]) {
  await page.evaluate((y) => window.scrollTo(0, y), y);
  await page.waitForTimeout(100);
  console.log(`scrollY=${y}:`, await page.evaluate(() => window.log.splice(0).join(", ")));
}
await browser.close();
$ node observer.mjs
scrollY=0: s1 visible, s2 visible
scrollY=700: s3 visible
scrollY=1400: s4 visible, s5 visible

With a 600 px viewport and rootMargin: "200px", the first callback fired for sections 1 and 2 — section 2 starts at 500 px, inside the extended margin, so it “counts” before it’s actually on screen. That head start is what lets a lazy-loaded image arrive before the user sees an empty box. Each section is reported once because the callback calls unobserve after the first hit.

Web Workers

A worker runs a script on another thread. It can’t touch the DOM; it talks to the page only through postMessage.

import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.route("https://app.test/**", (route) => route.fulfill({ body: "<!doctype html>", contentType: "text/html" }));
await page.goto("https://app.test/");

console.log(await page.evaluate(async () => {
  const source = `self.onmessage = (e) => {
    let count = 0;
    for (let n = 2; n <= e.data; n++) { let p = true; for (let i = 2; i * i <= n; i++) if (n % i === 0) { p = false; break; } if (p) count++; }
    self.postMessage({ count, hasDocument: typeof document !== "undefined" });
  };`;
  const worker = new Worker(URL.createObjectURL(new Blob([source], { type: "text/javascript" })));

  let frames = 0;
  const tick = () => { frames++; if (!done) requestAnimationFrame(tick); };
  let done = false;
  requestAnimationFrame(tick);

  const start = performance.now();
  const result = await new Promise((resolve) => { worker.onmessage = (e) => resolve(e.data); worker.postMessage(2_000_000); });
  done = true;
  worker.terminate();
  return `${result.count} primes in ${Math.round(performance.now() - start)} ms; ` +
         `worker sees document: ${result.hasDocument}; page kept animating: ${frames > 5}`;
}));
await browser.close();
$ node worker.mjs
148933 primes in 494 ms; worker sees document: false; page kept animating: true

The page kept producing animation frames while the worker computed, so a real UI would have stayed responsive. document doesn’t exist inside the worker. Usually the worker is a separate file — new Worker("/worker.js", { type: "module" }); the Blob URL just keeps this example in one file. Large ArrayBuffers can be transferred rather than copied (see the performance lesson).

WebSockets with Reconnection

A WebSocket is a persistent two-way connection; either side can send at any time. Connections drop, so production clients reconnect with backoff. The server uses the ws package; the client is the standard WebSocket class, which Node 22 provides just like browsers do.

import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 0 });
let connections = 0;
wss.on("connection", (socket) => {
  connections += 1;
  socket.send(JSON.stringify({ hello: `connection #${connections}` }));
  if (connections < 3) setTimeout(() => socket.close(), 50);
});

class ReconnectingSocket {
  delay = 100;
  constructor(url, onMessage) { this.url = url; this.onMessage = onMessage; this.connect(); }
  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => { this.delay = 100; };
    this.ws.onmessage = (e) => this.onMessage(JSON.parse(e.data));
    this.ws.onclose = () => {
      if (this.stopped) return;
      console.log(`closed; reconnecting in ${this.delay} ms`);
      setTimeout(() => this.connect(), this.delay);
      this.delay = Math.min(this.delay * 2, 30_000);
    };
  }
  close() { this.stopped = true; this.ws.close(); }
}

const client = new ReconnectingSocket(`ws://localhost:${wss.address().port}`, (msg) => {
  console.log("message:", msg);
  if (msg.hello.endsWith("#3")) { client.close(); wss.close(); }
});
$ node socket.mjs
message: { hello: 'connection #1' }
closed; reconnecting in 100 ms
message: { hello: 'connection #2' }
closed; reconnecting in 100 ms
message: { hello: 'connection #3' }

The server dropped the first two connections and the client came back each time. Both waits were 100 ms because a successful onopen resets the backoff — exponential growth only kicks in when connecting keeps failing, which is what stops thousands of clients hammering a server that has just restarted. The stopped flag matters too: without it, close() would trigger a reconnect.

Geolocation

The Geolocation API is callback-based and needs the user’s permission. Wrap it in a promise. Here Playwright grants the permission and supplies fixed coordinates — a real browser would ask the user and read the device’s location.

import { chromium } from "playwright";
const browser = await chromium.launch();

for (const permissions of [["geolocation"], []]) {
  const context = await browser.newContext({ permissions, geolocation: { latitude: 51.5007, longitude: -0.1246 } });
  const page = await context.newPage();
  await page.route("https://app.test/**", (route) => route.fulfill({ body: "<!doctype html>", contentType: "text/html" }));
  await page.goto("https://app.test/");

  console.log(await page.evaluate(async () => {
    const getPosition = (options = {}) => new Promise((resolve, reject) =>
      navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 2000, maximumAge: 60_000, ...options }));
    try {
      const { latitude, longitude, accuracy } = (await getPosition()).coords;
      return `location: ${latitude}, ${longitude} (±${accuracy} m)`;
    } catch (err) {
      return `error code ${err.code} (${err.code === err.PERMISSION_DENIED ? "PERMISSION_DENIED" : "other"}): ${err.message}`;
    }
  }));
  await context.close();
}
await browser.close();
$ node geo.mjs
location: 51.5007, -0.1246 (±0 m)
error code 1 (PERMISSION_DENIED): User denied Geolocation

With permission, the promise resolved with the coordinates Playwright supplied (with a simulated accuracy of 0 m — a real device reports tens or hundreds of metres, so always look at accuracy). Without permission, it rejected with a GeolocationPositionError whose code is 1, PERMISSION_DENIED — branch on the code, not the message. Geolocation only works on secure origins — served over plain http://app.test, this same script got PERMISSION_DENIED with the message “Only secure origins are allowed” even with permission granted — and should be requested in response to a user action, never on page load.

Clipboard

navigator.clipboard reads and writes the system clipboard, asynchronously and only with permission.

import { chromium } from "playwright";
const browser = await chromium.launch();

for (const permissions of [["clipboard-read", "clipboard-write"], []]) {
  const context = await browser.newContext({ permissions });
  const page = await context.newPage();
  await page.route("https://app.test/**", (route) => route.fulfill({ body: "<!doctype html>", contentType: "text/html" }));
  await page.goto("https://app.test/");

  console.log(`permissions [${permissions.join(", ")}]:`, await page.evaluate(async () => {
    try {
      await navigator.clipboard.writeText("npm install playwright");
      return `copied, read back: "${await navigator.clipboard.readText()}"`;
    } catch (err) {
      return `${err.name}: ${err.message}`;
    }
  }));
  await context.close();
}
await browser.close();
$ node clipboard.mjs
permissions [clipboard-read, clipboard-write]: copied, read back: "npm install playwright"
permissions []: NotAllowedError: Failed to execute 'writeText' on 'Clipboard': Write permission denied.

With permission, the text round-tripped. Without it, the call was refused. In a real page, writing is normally allowed during a user gesture — call it from a click handler — while reading pops up a permission prompt, so reserve it for explicit “paste” buttons. The old document.execCommand("copy") fallback is deprecated; use it only if you must support very old browsers.

Common Pitfalls

  • CORS — a browser fetch to another origin fails unless that server allows it, and the catch only sees a generic TypeError: Failed to fetch; the real reason is in the DevTools console (see the CORS section of the security lesson).
  • Storage limits — about 5 MB per origin for localStorage; use IndexedDB for more, and treat every write as fallible.
  • Workers share nothing — data is copied via postMessage; transfer big buffers instead.
  • Permissions — geolocation, clipboard and notifications need user consent and a secure origin; always handle the denied case.

Practice

1. Add a retries option to apiFetch that retries only on 5xx responses.
import http from "node:http";
let hits = 0;
const server = http.createServer((req, res) => {
  hits += 1;
  if (hits < 3) res.writeHead(503).end(); else res.end('{"ok": true}');
}).listen(0);

async function apiFetch(url, { retries = 0 } = {}) {
  const res = await fetch(url);
  if (res.status >= 500 && retries > 0) {
    console.log(`got ${res.status}, retrying (${retries} left)`);
    return apiFetch(url, { retries: retries - 1 });
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

console.log(await apiFetch(`http://localhost:${server.address().port}/`, { retries: 3 }));
server.close();
$ node p1.mjs
got 503, retrying (3 left)
got 503, retrying (2 left)
{ ok: true }

Retrying only 5xx matters: a 4xx means the request itself is wrong, and repeating it will fail the same way.

2. Build a typed storage.get(key, fallback) that survives corrupted JSON in localStorage.
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.route("https://app.test/**", (route) => route.fulfill({ body: "<!doctype html>", contentType: "text/html" }));
await page.goto("https://app.test/");

console.log(await page.evaluate(() => {
  const get = (key, fallback = null) => {
    try { const raw = localStorage.getItem(key); return raw === null ? fallback : JSON.parse(raw); }
    catch { return fallback; }
  };
  localStorage.setItem("prefs", "{broken json");
  localStorage.setItem("theme", JSON.stringify("dark"));
  return JSON.stringify([get("prefs", {}), get("theme"), get("missing", "light")]);
}));
await browser.close();
$ node p2.mjs
[{},"dark","light"]

Anything in storage may have been written by an older version of your app — or edited by the user — so parse defensively.

3. Make ReconnectingSocket give up after three consecutive failed connection attempts.
class ReconnectingSocket {
  delay = 50; failures = 0;
  constructor(url) { this.url = url; this.connect(); }
  connect() {
    const ws = new WebSocket(this.url);
    let handled = false;
    ws.onopen = () => { this.failures = 0; };
    ws.onerror = ws.onclose = () => {
      if (handled) return;
      handled = true;
      this.failures += 1;
      if (this.failures >= 3) return console.log("giving up after 3 failures");
      console.log(`attempt ${this.failures} failed; retrying in ${this.delay} ms`);
      setTimeout(() => this.connect(), this.delay);
      this.delay *= 2;
    };
  }
}
new ReconnectingSocket("ws://127.0.0.1:59999");
$ node p3.mjs
attempt 1 failed; retrying in 50 ms
attempt 2 failed; retrying in 100 ms
giving up after 3 failures

Nothing listens on that port, so every attempt fails immediately; the backoff doubles each time and the client stops instead of retrying forever. The handler is attached to both error and close with a guard because a failed connection behaves differently across runtimes: browsers fire error then close, while Node 22’s WebSocket fires only error.

Frequently Asked Questions

What is the difference between localStorage and sessionStorage?
Both store key-value string pairs in the browser, but localStorage persists across sessions (until explicitly cleared), while sessionStorage is cleared when the browser tab is closed. Neither is secure for sensitive data — both are accessible via JavaScript and vulnerable to XSS attacks.
Why does Fetch not reject on HTTP error status codes like 404 or 500?
Fetch only rejects on network failures (no connection, DNS failure, CORS block). A 404 or 500 is still a valid HTTP response, so the promise resolves. You must check response.ok or response.status manually and throw if needed.
When should I use a Web Worker?
Use a Web Worker for CPU-heavy tasks that would block the main thread — parsing large JSON, image processing, cryptography, sorting large datasets. Workers run in a separate thread, so they cannot access the DOM, but they can use fetch, timers, and most other APIs.