Skip to content

Request Deduplication and Caching

advanced19 min read

47 Identical Requests in 200 Milliseconds

A React app renders a user avatar in the header, sidebar, comment section, and 12 comment reply components. Each component independently calls fetchUser(userId). The component tree renders, and suddenly the network tab shows 15 identical GET requests to /api/users/42 — all fired within a single render cycle.

Multiply by every user on the page, every component that needs shared data, and you've got a performance disaster. The server is doing 47 database queries for data it already looked up on the first request.

This is the thundering herd problem in frontend — and it's far more common than people realize.

The Thundering Herd Problem

Mental Model

Imagine 50 people in an office all asking the same question: "What's the Wi-Fi password?" The first person asks IT. While IT is looking it up, 49 more people call. Each call creates a new ticket, a new lookup, a new response. The smart solution: the first person asks, everyone else waits for that one answer, and it gets shared. That's request deduplication.

The thundering herd happens when multiple consumers request the same resource simultaneously, and each triggers an independent fetch. Common causes:

  • Multiple React components rendering with the same data dependency
  • Rapid user interactions (clicking a button multiple times)
  • Server-side rendering where multiple route segments need the same data
  • Cache expiration causing simultaneous revalidation from multiple clients

Request Deduplication With In-Flight Tracking

The core idea: keep a map of in-flight requests. If someone asks for data that's already being fetched, return the same promise instead of starting a new fetch.

const inFlight = new Map();

function dedupedFetch(url) {
  if (inFlight.has(url)) {
    return inFlight.get(url);
  }

  const promise = fetch(url)
    .then(res => res.json())
    .finally(() => {
      inFlight.delete(url);
    });

  inFlight.set(url, promise);
  return promise;
}

Now 15 components calling dedupedFetch('/api/users/42') results in one network request. The first call creates the promise and stores it. The next 14 calls get the same promise back. When it resolves, all 15 components get the same data.

Quiz
Why does the deduplication map use .finally() to clean up instead of .then()?

Keyed Deduplication for Complex Requests

URL alone isn't always enough. POST requests with different bodies, or GET requests with different headers, need more specific keys:

function createDeduper() {
  const inFlight = new Map();

  return function dedup(key, fetcher) {
    if (inFlight.has(key)) {
      return inFlight.get(key);
    }

    const promise = fetcher().finally(() => {
      inFlight.delete(key);
    });

    inFlight.set(key, promise);
    return promise;
  };
}

const dedup = createDeduper();

const user = await dedup('user-42', () =>
  fetch('/api/users/42').then(r => r.json())
);

const search = await dedup('search-react-hooks', () =>
  fetch('/api/search?q=react+hooks').then(r => r.json())
);

The Stale-While-Revalidate (SWR) Pattern

Deduplication prevents redundant concurrent requests. But what about subsequent requests for the same data? Cache it. The SWR pattern returns cached data immediately (fast), then revalidates in the background (fresh):

Here's a minimal SWR implementation:

function createCache() {
  const cache = new Map();
  const subscribers = new Map();
  const inFlight = new Map();

  function subscribe(key, callback) {
    if (!subscribers.has(key)) {
      subscribers.set(key, new Set());
    }
    subscribers.get(key).add(callback);
    return () => subscribers.get(key).delete(callback);
  }

  function notify(key, data) {
    const subs = subscribers.get(key);
    if (subs) {
      for (const cb of subs) cb(data);
    }
  }

  async function revalidate(key, fetcher) {
    if (inFlight.has(key)) return inFlight.get(key);

    const promise = fetcher().then(data => {
      cache.set(key, { data, timestamp: Date.now() });
      notify(key, data);
      return data;
    }).finally(() => {
      inFlight.delete(key);
    });

    inFlight.set(key, promise);
    return promise;
  }

  function get(key, fetcher, options = {}) {
    const { maxAge = 60000 } = options;
    const entry = cache.get(key);

    if (entry) {
      const age = Date.now() - entry.timestamp;
      if (age > maxAge) {
        revalidate(key, fetcher);
      }
      return entry.data;
    }

    return revalidate(key, fetcher);
  }

  return { get, subscribe, revalidate };
}
Quiz
What is the key advantage of stale-while-revalidate over a simple cache with TTL expiration?

Cache Invalidation Strategies

Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. He was right about both. Here are the strategies that actually work:

Time-Based Invalidation (TTL)

The simplest: data expires after a fixed duration.

function isStale(entry, maxAge) {
  return Date.now() - entry.timestamp > maxAge;
}

Good for: configuration data, feature flags, anything that changes on a known schedule. Bad for: user-generated content that needs to reflect changes immediately.

Event-Based Invalidation

Invalidate when you know data has changed:

const cache = createCache();

async function updateUserName(userId, newName) {
  await fetch(`/api/users/${userId}`, {
    method: 'PATCH',
    body: JSON.stringify({ name: newName }),
  });

  cache.revalidate(`user-${userId}`, () =>
    fetch(`/api/users/${userId}`).then(r => r.json())
  );
}

After a mutation, immediately revalidate the affected cache entry. This is the pattern libraries like SWR and React Query use via their mutate functions.

Optimistic Updates

For the snappiest UX, update the cache before the server confirms:

const postCache = new Map();

async function toggleLike(postId, isLiked) {
  const key = `post-${postId}`;
  const prev = postCache.get(key);

  postCache.set(key, {
    ...prev,
    isLiked: !isLiked,
    likes: isLiked ? prev.likes - 1 : prev.likes + 1,
  });

  try {
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  } catch {
    postCache.set(key, prev);
  }
}

Update instantly, rollback on failure. The user sees the change immediately. If the server rejects it, revert to the previous state.

Common Trap

Optimistic updates get tricky with concurrent mutations. If the user likes a post and immediately edits it, and the like fails, rolling back to the pre-like state also reverts the edit. Use versioned snapshots or mutation queues for complex cases.

Tag-Based Invalidation

Group related cache entries by tags, then invalidate all entries with a specific tag:

function createTaggedCache() {
  const cache = new Map();
  const tags = new Map();

  function set(key, value, entryTags = []) {
    cache.set(key, value);
    for (const tag of entryTags) {
      if (!tags.has(tag)) tags.set(tag, new Set());
      tags.get(tag).add(key);
    }
  }

  function invalidateTag(tag) {
    const keys = tags.get(tag);
    if (keys) {
      for (const key of keys) cache.delete(key);
      tags.delete(tag);
    }
  }

  return { get: (k) => cache.get(k), set, invalidateTag };
}

const cache = createTaggedCache();
cache.set('user-42', userData, ['users', 'team-7']);
cache.set('user-43', userData2, ['users', 'team-7']);

cache.invalidateTag('team-7');

This is similar to how Next.js revalidateTag works under the hood.

Quiz
A social media feed shows posts from many users. When user A updates their profile name, which cache invalidation strategy is most appropriate?

React Suspense and Async Caching

React's use() hook integrates directly with promise-based caching. The pattern: create a cache that returns promises, and let Suspense handle the loading state:

const userCache = new Map();

function fetchUser(id) {
  if (!userCache.has(id)) {
    userCache.set(id, fetch(`/api/users/${id}`).then(r => r.json()));
  }
  return userCache.get(id);
}
import { use, Suspense } from 'react';

function UserProfile({ userId }) {
  const user = use(fetchUser(userId));
  return <h1>{user.name}</h1>;
}

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userId={42} />
    </Suspense>
  );
}

Multiple components calling fetchUser(42) all share the same promise. React's use() unwraps it. Suspense shows the fallback until the promise resolves. No loading state management in the component.

React's use() hook is special — it can be called conditionally (unlike other hooks), and it works with any thenable, not just native Promises. When the promise is pending, use() throws it (yes, throws a promise), and the nearest Suspense boundary catches it and shows the fallback. When the promise resolves, React re-renders the component with the resolved value. This "throw a promise" pattern is what makes Suspense work.

Putting It All Together: A Complete Data Layer

Here's how deduplication, caching, and revalidation compose:

function createDataLayer() {
  const cache = new Map();
  const inFlight = new Map();

  function query(key, fetcher, options = {}) {
    const { maxAge = 30000, forceRefresh = false } = options;

    if (!forceRefresh) {
      const entry = cache.get(key);
      if (entry && Date.now() - entry.timestamp < maxAge) {
        return Promise.resolve(entry.data);
      }
    }

    if (inFlight.has(key)) {
      return inFlight.get(key);
    }

    const promise = fetcher()
      .then(data => {
        cache.set(key, { data, timestamp: Date.now() });
        return data;
      })
      .finally(() => {
        inFlight.delete(key);
      });

    inFlight.set(key, promise);
    return promise;
  }

  function invalidate(key) {
    cache.delete(key);
  }

  function mutate(key, data) {
    cache.set(key, { data, timestamp: Date.now() });
  }

  return { query, invalidate, mutate };
}

Three layers in 30 lines: fresh cache returns instantly, stale cache triggers background revalidation, and concurrent requests are deduplicated. This is essentially what SWR and React Query do at their core — everything else is ergonomics.

What developers doWhat they should do
Every component independently fetches shared data
Independent fetches create thundering herd effects. 15 components needing the same user data should result in 1 network request, not 15
Deduplicate requests through a shared cache layer or use React Query/SWR
Showing loading spinners on every data refresh
Users see instant data from cache. Background revalidation updates the UI when fresh data arrives, with zero perceived loading time
Use stale-while-revalidate to show cached data while refreshing
Invalidating entire caches after a mutation
Broad invalidation causes unnecessary refetches and loading states for unrelated data
Surgically invalidate only affected entries using keys or tags
Using URLs as the only cache key
The same URL with different auth tokens returns different data. Using URL alone can serve one user's data to another
Include relevant parameters, headers, or auth tokens in the cache key
Interview Question

Design a client-side data layer for a chat application. Messages are fetched per conversation, users type and send messages frequently, and the same user profile appears in multiple conversations. How would you handle: deduplication of user profile fetches, optimistic message sending, cache invalidation when new messages arrive via WebSocket, and memory management for conversations the user has scrolled past?