Skip to main content
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, Geolocation, and the Clipboard API.

Modern browsers expose a rich set of APIs that go far beyond the DOM. This guide covers the ones you’ll reach for most often in production applications — how to use them correctly, and the gotchas that trip up developers the first time.

Fetch API

fetch is the modern replacement for XMLHttpRequest. It returns a Promise and integrates cleanly with async/await, making HTTP requests straightforward to write and read. The critical thing to know upfront: Fetch does not reject on HTTP error status codes. A 404 or 500 still resolves the Promise — you must check response.ok yourself and throw if the request failed. Building a reusable wrapper prevents this mistake from appearing throughout your codebase.

// A reusable fetch wrapper with proper error handling
async function apiFetch(url, options = {}) {
  const defaultOptions = {
    headers: {
      "Content-Type": "application/json",
      ...options.headers,
    },
  };

  const response = await fetch(url, { ...defaultOptions, ...options });

  if (!response.ok) {
    // Include status in the error so callers can branch on it
    const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
    error.status = response.status;
    throw error;
  }

  // Handle 204 No Content — response.json() would throw on an empty body
  if (response.status === 204) return null;

  return response.json();
}

// POST example
async function createUser(userData) {
  try {
    const user = await apiFetch("/api/users", {
      method: "POST",
      body: JSON.stringify(userData),
    });
    console.log("Created:", user);
    return user;
  } catch (err) {
    if (err.status === 409) {
      console.error("User already exists");
    } else {
      console.error("Unexpected error:", err.message);
    }
    throw err;
  }
}

For request cancellation, use AbortController. This is especially important for search-as-you-type features — you want to cancel the previous request when the user types another character:

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout

try {
  const data = await apiFetch("/api/slow-endpoint", {
    signal: controller.signal,
  });
  clearTimeout(timeoutId);
  return data;
} catch (err) {
  if (err.name === "AbortError") {
    console.warn("Request timed out");
  }
}

localStorage and sessionStorage

localStorage and sessionStorage let you persist data in the browser without a server round-trip — useful for user preferences, cached API responses, or form drafts. Both APIs only store strings, so you need to serialize and parse JSON. A thin wrapper centralizes the serialization logic and handles the QuotaExceededError that occurs when storage is full or the user is in private mode on some browsers.

const storage = {
  get(key, fallback = null) {
    try {
      const item = localStorage.getItem(key);
      // getItem returns null for missing keys — distinguish from stored null
      return item !== null ? JSON.parse(item) : fallback;
    } catch {
      return fallback; // JSON.parse failed or localStorage unavailable
    }
  },

  set(key, value) {
    try {
      localStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (err) {
      // QuotaExceededError: storage full, or private mode in some browsers
      console.warn("localStorage.set failed:", err);
      return false;
    }
  },

  remove(key) {
    localStorage.removeItem(key);
  },

  clear() {
    localStorage.clear();
  },
};

// Usage
storage.set("user", { id: 1, name: "Alice", theme: "dark" });
const user = storage.get("user");          // { id: 1, name: 'Alice', theme: 'dark' }
const missing = storage.get("nope", {});   // {} — fallback returned

Use sessionStorage the same way — swap localStorage for sessionStorage in the wrapper.

Intersection Observer

Scroll event listeners are expensive — they fire on every pixel scrolled and force layout recalculations. IntersectionObserver is the browser-native alternative: it fires a callback asynchronously when elements enter or exit the viewport, with no scroll listener required. This makes it the right tool for lazy loading images, triggering animations when elements come into view, and implementing infinite scroll.

// Lazy-load images: use data-src, set src only when image enters the viewport
function lazyLoadImages() {
  const observer = new IntersectionObserver(
    (entries, obs) => {
      entries.forEach((entry) => {
        if (!entry.isIntersecting) return;

        const img = entry.target;
        img.src = img.dataset.src;       // trigger the actual download
        img.removeAttribute("data-src"); // clean up the data attribute
        obs.unobserve(img);              // stop watching — no need to fire again
      });
    },
    {
      rootMargin: "200px", // start loading 200px before the image enters the viewport
      threshold: 0,        // fire as soon as any part of the image is visible
    }
  );

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

  return observer; // keep a reference so you can call disconnect() when done
}

// HTML: <img data-src="/photo.jpg" alt="Photo" width="800" height="600">
lazyLoadImages();

Web Workers

JavaScript is single-threaded — any CPU-heavy computation blocks the main thread, freezing the UI. Web Workers solve this by running scripts on a background thread. The tradeoff is that workers have no access to the DOM; they communicate with the main thread exclusively via postMessage. This isolation is actually a feature — it prevents workers from accidentally causing race conditions on the UI.

// worker.js — runs in its own thread, completely isolated from the DOM
self.onmessage = function (e) {
  const { numbers } = e.data;

  // CPU-heavy work: sieve of Eratosthenes to find all primes up to `numbers`
  const primes = [];
  for (let n = 2; n <= numbers; n++) {
    let isPrime = true;
    for (let i = 2; i <= Math.sqrt(n); i++) {
      if (n % i === 0) { isPrime = false; break; }
    }
    if (isPrime) primes.push(n);
  }

  // Send results back to the main thread
  self.postMessage({ primes, count: primes.length });
};

// main.js — UI thread stays responsive the entire time
const worker = new Worker("/worker.js");

worker.onmessage = (e) => {
  console.log(`Found ${e.data.count} primes`);
  worker.terminate(); // free up the background thread when done
};

worker.onerror = (err) => {
  console.error("Worker error:", err.message);
};

worker.postMessage({ numbers: 1_000_000 }); // UI stays fully interactive

WebSockets

HTTP is request-response — the client asks, the server answers, then the connection closes. WebSockets provide a persistent, full-duplex connection where either side can send data at any time. This makes them ideal for chat, live dashboards, collaborative editing, and multiplayer games. In production, you almost always need reconnection logic since connections can drop.

class ReconnectingWebSocket {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectDelay = 1000;
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log("Connected");
      this.reconnectDelay = 1000; // reset exponential backoff on successful connect
    };

    this.ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      this.onMessage(data);
    };

    this.ws.onclose = () => {
      console.warn(`Disconnected. Reconnecting in ${this.reconnectDelay}ms`);
      setTimeout(() => this.connect(), this.reconnectDelay);
      // Exponential backoff — avoid hammering the server on repeated failures
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000);
    };
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }

  onMessage(data) {
    // Override in subclass or reassign as a property
    console.log("Message:", data);
  }
}

Geolocation API

The Geolocation API provides the device’s physical location, but it requires user permission and may not be available in all environments. Wrapping the callback-based API in a Promise makes it easier to use with async/await and lets you handle the permission-denied case cleanly.

async function getCurrentPosition(options = {}) {
  return new Promise((resolve, reject) => {
    if (!navigator.geolocation) {
      reject(new Error("Geolocation not supported"));
      return;
    }

    navigator.geolocation.getCurrentPosition(resolve, reject, {
      enableHighAccuracy: false,  // true uses GPS but drains battery faster
      timeout: 10_000,            // fail if no position within 10 seconds
      maximumAge: 60_000,         // accept a cached position up to 1 minute old
      ...options,
    });
  });
}

try {
  const position = await getCurrentPosition();
  const { latitude, longitude, accuracy } = position.coords;
  console.log(`Location: ${latitude}, ${longitude} (±${accuracy}m)`);
} catch (err) {
  if (err.code === GeolocationPositionError.PERMISSION_DENIED) {
    console.error("User denied location access");
  }
}

Clipboard API

The Clipboard API provides programmatic access to the system clipboard — essential for “Copy to clipboard” buttons. It requires a user gesture (a click) and HTTPS in most browsers. The fallback using document.execCommand covers older browsers where the Clipboard API is not available.

// Write to clipboard — requires a user gesture or HTTPS
async function copyToClipboard(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    // Fallback for older browsers using the deprecated execCommand API
    const el = document.createElement("textarea");
    el.value = text;
    el.style.position = "fixed";
    el.style.opacity = "0";
    document.body.appendChild(el);
    el.select();
    const success = document.execCommand("copy");
    document.body.removeChild(el);
    return success;
  }
}

// Read from clipboard — requires clipboard-read permission prompt
async function readFromClipboard() {
  try {
    return await navigator.clipboard.readText();
  } catch {
    return null; // permission denied or API not available
  }
}

Common Pitfalls

  • CORS: fetch respects CORS. If the server doesn’t send the right headers, the browser blocks the response — the error appears in DevTools Network, not in your catch block as a useful message.
  • localStorage size: The limit is ~5MB per origin. For large data, use IndexedDB.
  • Web Workers can’t share state: Pass data via postMessage. For large buffers (images, audio), use Transferable objects to avoid copying: worker.postMessage({ buffer }, [buffer]).
  • WebSocket binary: Use ws.binaryType = 'arraybuffer' before connecting if you expect binary frames.

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.