Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 072 / 100

Web APIs every developer should know: a practical guide

Built-in tools that replace libraries.

Expona Admin9 min read

Over the years, I've watched developers reach for external libraries to solve problems that the browser already handles natively. Whether it's cancelling network requests, observing element visibility, or syncing state across tabs, the Web APIs every developer should know are often overlooked in favour of third-party solutions. On Day 72 of this series, we're exploring four powerful built-in tools that can eliminate dependency bloat and give you more control over your application's behaviour.

Why Native Web APIs Matter

Before diving into specifics, let me be clear: learning these APIs isn't about abandoning libraries. It's about making informed decisions. When you understand what the browser provides, you can confidently evaluate whether a library adds value or just wraps existing functionality. I've reduced bundle sizes by 10–15 KB simply by removing packages that duplicate these native capabilities.

The browser standardizes these APIs across modern versions, meaning they're well-tested, performant, and future-proof. You're also reducing attack surface—fewer dependencies means fewer potential vulnerabilities to audit.

AbortController — Cancel Operations with Precision

Imagine a user triggers a search, waits two seconds, then changes their mind and searches for something else. Without proper cancellation, both requests fire, and the slower one might overwrite the faster response. This is where AbortController becomes essential.

AbortController gives you a clean way to cancel fetch requests, abort event listeners, and even cancel timers (when paired with AbortSignal). I've used this extensively in real-time search features across e-commerce platforms.

typescript
// Basic fetch cancellation
const controller = new AbortController();

const searchUser = async (query: string) => {
  try {
    const response = await fetch(`/api/users?q=${query}`, {
      signal: controller.signal
    });
    const data = await response.json();
    console.log('Users found:', data);
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      console.log('Request was cancelled');
    } else {
      throw error;
    }
  }
};

// User types again, cancel previous request
controller.abort();

In a React component with input debouncing, this pattern prevents race conditions:

typescript
import { useEffect, useRef, useState } from 'react';

export function UserSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const controllerRef = useRef(null);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    // Cancel previous request if still pending
    if (controllerRef.current) {
      controllerRef.current.abort();
    }

    controllerRef.current = new AbortController();
    const signal = controllerRef.current.signal;

    const timer = setTimeout(async () => {
      try {
        const res = await fetch(`/api/users?q=${query}`, { signal });
        const data = await res.json();
        setResults(data);
      } catch (error) {
        if (!(error instanceof DOMException && error.name === 'AbortError')) {
          console.error('Search failed:', error);
        }
      }
    }, 300);

    return () => {
      clearTimeout(timer);
      if (controllerRef.current) {
        controllerRef.current.abort();
      }
    };
  }, [query]);

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search users..."
      />
      <ul>
        {results.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </>
  );
}

The beauty here is that you're not dependent on a library's cancellation implementation. You control exactly when requests stop, reducing unnecessary server load and improving perceived performance.

IntersectionObserver — Efficient Visibility Tracking

Lazy-loading images and implementing infinite scroll used to require scroll event listeners and manual intersection calculations. In a high-traffic ride-hailing platform I worked on, we had performance issues because scroll listeners fired constantly, triggering expensive layout recalculations.

IntersectionObserver solves this by asynchronously detecting when elements enter or leave the viewport. The browser optimizes this internally, making it far more efficient than scroll event handlers.

typescript
// Lazy-load images
const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const img = entry.target as HTMLImageElement;
      img.src = img.dataset.src || '';
      img.classList.add('loaded');
      imageObserver.unobserve(img);
    }
  });
}, {
  threshold: 0.1, // Trigger when 10% of image is visible
  rootMargin: '50px' // Start loading 50px before entering viewport
});

// Observe all lazy-load images
document.querySelectorAll('img[data-src]').forEach((img) => {
  imageObserver.observe(img);
});

Here's a practical React hook for infinite scroll in a feed:

typescript
import { useEffect, useRef, useCallback } from 'react';

export function useInfiniteScroll(callback: () => void) {
  const sentinelRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) {
          callback();
        }
      },
      { threshold: 0.5 }
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => observer.disconnect();
  }, [callback]);

  return sentinelRef;
}

// Usage in a feed component
export function Feed() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);

  const sentinelRef = useInfiniteScroll(() => {
    setPage((p) => p + 1);
  });

  useEffect(() => {
    const loadMore = async () => {
      const res = await fetch(`/api/feed?page=${page}`);
      const data = await res.json();
      setItems((prev) => [...prev, ...data]);
    };
    loadMore();
  }, [page]);

  return (
    <>
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
      <div ref={sentinelRef} />
    </>
  );
}

This approach eliminated a third-party scroll tracking library from a travel booking platform, reducing complexity and improving maintainability.

BroadcastChannel — Sync State Across Tabs

I built a feature for a CRM system where users could have multiple tabs open. When they updated a customer record in one tab, I needed that change to reflect instantly in other tabs. BroadcastChannel is the native way to handle this without server-round-trips.

typescript
// Create a broadcast channel for customer updates
const channel = new BroadcastChannel('customer-updates');

// Listen for messages from other tabs
channel.onmessage = (event) => {
  const { type, payload } = event.data;

  if (type === 'CUSTOMER_UPDATED') {
    console.log('Customer updated in another tab:', payload);
    // Update local state, re-fetch, or dispatch action
    updateCustomerInUI(payload);
  }
};

// Notify all tabs when this tab updates
const updateCustomer = async (id: string, data: any) => {
  const response = await fetch(`/api/customers/${id}`, {
    method: 'PUT',
    body: JSON.stringify(data)
  });
  const updated = await response.json();

  // Broadcast to all tabs
  channel.postMessage({
    type: 'CUSTOMER_UPDATED',
    payload: updated
  });
};

// Clean up when done
// channel.close();

Here's a React Context example for syncing authentication state:

typescript
import React, { createContext, useEffect, useState } from 'react';

interface AuthContextType {
  user: { id: string; email: string } | null;
  logout: () => void;
}

export const AuthContext = createContext({
  user: null,
  logout: () => {}
});

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState(null);
  const channelRef = React.useRef(null);

  useEffect(() => {
    // Initialize broadcast channel
    channelRef.current = new BroadcastChannel('auth-sync');

    channelRef.current.onmessage = (event) => {
      if (event.data.type === 'LOGOUT') {
        setUser(null);
        localStorage.removeItem('token');
      }
      if (event.data.type === 'LOGIN') {
        setUser(event.data.user);
      }
    };

    return () => channelRef.current?.close();
  }, []);

  const logout = () => {
    setUser(null);
    localStorage.removeItem('token');
    channelRef.current?.postMessage({ type: 'LOGOUT' });
  };

  return (
    <AuthContext.Provider value={{ user, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

This works only within the same origin and same browser session, but it's perfect for coordinating state across tabs without external services.

URL and URLSearchParams — Safe URL Handling

String concatenation for URLs is error-prone. Missing ampersands, unencoded special characters, and difficult debugging are common issues. I've seen this cause subtle bugs in query-heavy applications like trading platforms.

URL and URLSearchParams handle encoding, parsing, and validation automatically:

typescript
// Building a URL safely
const url = new URL('https://api.example.com/users');

// Add query parameters
url.searchParams.set('page', '1');
url.searchParams.set('limit', '20');
url.searchParams.append('tag', 'active');
url.searchParams.append('tag', 'verified');

console.log(url.toString());
// https://api.example.com/users?page=1&limit=20&tag=active&tag=verified

// Fetch using the URL object
const response = await fetch(url);

Parsing and modifying existing URLs:

typescript
const currentUrl = new URL(window.location.href);

// Check if a parameter exists
if (currentUrl.searchParams.has('redirect')) {
  const redirectPath = currentUrl.searchParams.get('redirect');
  console.log('Redirect to:', redirectPath);
}

// Update and navigate
currentUrl.searchParams.set('tab', 'settings');
window.history.pushState({}, '', currentUrl);

// Remove a parameter
currentUrl.searchParams.delete('filter');

// Get all values for multi-valued params
const tags = currentUrl.searchParams.getAll('tag');
console.log(tags); // ['active', 'verified']

In a SaaS dashboard I worked on, this eliminated encoding bugs when filtering datasets. Complex filter states with multiple values are now handled reliably.

Common Mistakes

  • AbortController not cleaned up: Always abort in cleanup functions. Forgotten abort signals can cause memory leaks and silent failures in useEffect dependencies.
  • IntersectionObserver threshold confusion: threshold: 1 means the entire element must be visible. threshold: 0 means any part visible triggers the callback. Array values like [0, 0.5, 1] fire at multiple points.
  • BroadcastChannel cross-origin: It only works within the same origin. Don't expect it to sync across different domains—use server events or WebSockets instead.
  • URLSearchParams iteration: When iterating multiple values, use getAll() not just get(). The latter returns only the first value.
  • Not checking DOMException names: When catching abort errors, distinguish between abort and actual network failures by checking error.name === 'AbortError'.

Key Takeaways

  • AbortController cancels fetch requests and event listeners reliably, preventing race conditions and unnecessary server load.
  • IntersectionObserver replaces scroll event listeners for lazy-loading and visibility tracking with better performance.
  • BroadcastChannel syncs state across browser tabs within the same origin without external communication.
  • URL and URLSearchParams handle encoding and parsing safely, eliminating string manipulation bugs.
  • Understanding these APIs helps you evaluate libraries critically and reduce bundle size where they're unnecessary.
  • Always clean up observers and channels to avoid memory leaks in long-lived applications.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles