Front-end Engineering Lab

Request Deduplication

Prevent duplicate API requests for the same data

Multiple components requesting the same data at the same time can overload servers. This guide shows how to deduplicate requests.

🎯 The Problem

// Header needs user
function Header() {
  const { data } = useQuery('/api/user');
  return <div>{data.name}</div>;
}

// Sidebar needs user
function Sidebar() {
  const { data } = useQuery('/api/user');
  return <div>{data.email}</div>;
}

// Profile needs user
function Profile() {
  const { data } = useQuery('/api/user');
  return <div>{data.bio}</div>;
}

// Result: 3 identical API calls! ❌
// We should only make 1 call ✅

🔄 Pattern 1: Request Pooling

Share in-flight requests.

class RequestPool {
  private pending = new Map<string, Promise<any>>();
  
  async fetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
    // Check if request is already in flight
    if (this.pending.has(key)) {
      console.log(`Deduplicating request: ${key}`);
      return this.pending.get(key)!;
    }
    
    // Start new request
    const promise = fetcher().finally(() => {
      // Clean up after request completes
      this.pending.delete(key);
    });
    
    this.pending.set(key, promise);
    
    return promise;
  }
  
  clear(): void {
    this.pending.clear();
  }
}

export const requestPool = new RequestPool();

Usage

// API wrapper
async function fetchUser(): Promise<User> {
  return requestPool.fetch('user', async () => {
    const response = await fetch('/api/user');
    return response.json();
  });
}

// All three components call fetchUser()
// Only 1 actual HTTP request is made!

⚡ Pattern 2: React Query (Automatic Deduplication)

React Query handles this automatically.

import { useQuery } from '@tanstack/react-query';

// Header
function Header() {
  const { data } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
  });
  return <div>{data?.name}</div>;
}

// Sidebar
function Sidebar() {
  const { data } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
  });
  return <div>{data?.email}</div>;
}

// Profile
function Profile() {
  const { data } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser
  });
  return <div>{data?.bio}</div>;
}

// All three hooks share the same query!
// Only 1 HTTP request made ✅

With Parameters

// Multiple components need same product
function ProductImage({ id }: { id: string }) {
  const { data } = useQuery({
    queryKey: ['product', id],
    queryFn: () => fetchProduct(id)
  });
  return <img src={data?.image} />;
}

function ProductPrice({ id }: { id: string }) {
  const { data } = useQuery({
    queryKey: ['product', id],
    queryFn: () => fetchProduct(id)
  });
  return <span>${data?.price}</span>;
}

function ProductName({ id }: { id: string }) {
  const { data } = useQuery({
    queryKey: ['product', id],
    queryFn: () => fetchProduct(id)
  });
  return <h2>{data?.name}</h2>;
}

// Same queryKey = shared request ✅

🎯 Pattern 3: SWR (Built-in Deduplication)

SWR also deduplicates automatically.

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(r => r.json());

// Header
function Header() {
  const { data } = useSWR('/api/user', fetcher);
  return <div>{data?.name}</div>;
}

// Sidebar
function Sidebar() {
  const { data } = useSWR('/api/user', fetcher);
  return <div>{data?.email}</div>;
}

// Profile
function Profile() {
  const { data } = useSWR('/api/user', fetcher);
  return <div>{data?.bio}</div>;
}

// Same key = deduplicated! ✅

Configure Deduplication Window

import { SWRConfig } from 'swr';

function App() {
  return (
    <SWRConfig value={{
      dedupingInterval: 2000, // Dedupe requests within 2 seconds
      fetcher: (url) => fetch(url).then(r => r.json())
    }}>
      <YourApp />
    </SWRConfig>
  );
}

🔧 Pattern 4: Custom Hook with Deduplication

Build your own deduplicated fetcher.

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

// Global request cache
const requestCache = new Map<string, {
  promise: Promise<any>;
  data: any;
  error: any;
}>();

export function useDedupedFetch<T>(
  key: string,
  fetcher: () => Promise<T>
) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    let cancelled = false;
    
    async function fetchData() {
      try {
        // Check if request already exists
        if (requestCache.has(key)) {
          const cached = requestCache.get(key)!;
          
          if (cached.data) {
            setData(cached.data);
            setLoading(false);
            return;
          }
          
          // Wait for in-flight request
          const result = await cached.promise;
          if (!cancelled) {
            setData(result);
            setLoading(false);
          }
          return;
        }
        
        // Start new request
        const promise = fetcher();
        requestCache.set(key, { promise, data: null, error: null });
        
        const result = await promise;
        
        if (!cancelled) {
          setData(result);
          setLoading(false);
          
          // Update cache
          requestCache.set(key, { 
            promise, 
            data: result, 
            error: null 
          });
        }
      } catch (err) {
        if (!cancelled) {
          setError(err as Error);
          setLoading(false);
        }
      }
    }
    
    fetchData();
    
    return () => {
      cancelled = true;
    };
  }, [key]);
  
  return { data, error, loading };
}

Usage

function Header() {
  const { data } = useDedupedFetch('user', fetchUser);
  return <div>{data?.name}</div>;
}

function Sidebar() {
  const { data } = useDedupedFetch('user', fetchUser);
  return <div>{data?.email}</div>;
}

// Automatically deduplicated!

⏱️ Pattern 5: Time-Based Deduplication

Deduplicate requests within a time window.

class TimedDeduplicator {
  private cache = new Map<string, {
    promise: Promise<any>;
    timestamp: number;
  }>();
  
  private window: number; // milliseconds
  
  constructor(windowMs: number = 1000) {
    this.window = windowMs;
  }
  
  async fetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
    const now = Date.now();
    const cached = this.cache.get(key);
    
    // Reuse if within time window
    if (cached && (now - cached.timestamp) < this.window) {
      console.log(`Deduplicating (${now - cached.timestamp}ms old)`);
      return cached.promise;
    }
    
    // Start new request
    const promise = fetcher();
    this.cache.set(key, { promise, timestamp: now });
    
    // Clean up after completion
    promise.finally(() => {
      setTimeout(() => {
        this.cache.delete(key);
      }, this.window);
    });
    
    return promise;
  }
}

const deduplicator = new TimedDeduplicator(2000); // 2 second window

// Usage
async function fetchUser(): Promise<User> {
  return deduplicator.fetch('user', async () => {
    const response = await fetch('/api/user');
    return response.json();
  });
}

// Requests within 2 seconds are deduplicated

🎨 Pattern 6: GraphQL DataLoader

Batch and deduplicate GraphQL queries.

import DataLoader from 'dataloader';

// Create a DataLoader for users
const userLoader = new DataLoader<string, User>(async (ids) => {
  // Batch load all users at once
  console.log(`Batching ${ids.length} user requests`);
  
  const response = await fetch('/graphql', {
    method: 'POST',
    body: JSON.stringify({
      query: `
        query GetUsers($ids: [ID!]!) {
          users(ids: $ids) {
            id
            name
            email
          }
        }
      `,
      variables: { ids }
    })
  });
  
  const { data } = await response.json();
  
  // Return users in same order as ids
  return ids.map(id => data.users.find((u: User) => u.id === id));
});

// Usage
async function getUser(id: string): Promise<User> {
  return userLoader.load(id);
}

// These three calls are batched into ONE GraphQL query!
const user1 = await getUser('1');
const user2 = await getUser('2');
const user3 = await getUser('3');

With React

import { useEffect, useState } from 'react';

function UserProfile({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null);
  
  useEffect(() => {
    // Automatically batched!
    userLoader.load(id).then(setUser);
  }, [id]);
  
  if (!user) return <div>Loading...</div>;
  
  return <div>{user.name}</div>;
}

// Render multiple profiles
<UserProfile id="1" />
<UserProfile id="2" />
<UserProfile id="3" />

// Only 1 GraphQL request made! ✅

🔍 Pattern 7: Debounced Deduplication

Deduplicate rapid successive calls.

class DebouncedDeduplicator {
  private pending = new Map<string, {
    timer: NodeJS.Timeout;
    resolve: (value: any) => void;
    reject: (error: any) => void;
  }[]>();
  
  private delay: number;
  
  constructor(delayMs: number = 300) {
    this.delay = delayMs;
  }
  
  async fetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      // Get or create pending array
      if (!this.pending.has(key)) {
        this.pending.set(key, []);
      }
      
      const pending = this.pending.get(key)!;
      
      // Clear existing timer
      if (pending.length > 0) {
        clearTimeout(pending[0].timer);
      }
      
      // Create new timer
      const timer = setTimeout(async () => {
        try {
          // Execute fetcher once
          const result = await fetcher();
          
          // Resolve all pending promises
          pending.forEach(p => p.resolve(result));
          
          // Clear pending
          this.pending.delete(key);
        } catch (error) {
          // Reject all pending promises
          pending.forEach(p => p.reject(error));
          this.pending.delete(key);
        }
      }, this.delay);
      
      // Add to pending
      pending.push({ timer, resolve, reject });
    });
  }
}

const deduplicator = new DebouncedDeduplicator(300);

// Usage - rapid calls are debounced
async function search(query: string): Promise<Results> {
  return deduplicator.fetch(`search:${query}`, async () => {
    const response = await fetch(`/api/search?q=${query}`);
    return response.json();
  });
}

// User types "hello" quickly
search('h');    // ⏱️ timer starts
search('he');   // ⏱️ timer resets
search('hel');  // ⏱️ timer resets
search('hell'); // ⏱️ timer resets
search('hello'); // ⏱️ timer resets
// After 300ms: ONE request for "hello"

📊 Comparison Table

PatternComplexityUse CaseLibrary Needed
Request PoolLowSimple deduplicationNone
React QueryLowReact apps@tanstack/react-query
SWRLowReact appsswr
Custom HookMediumCustom logicNone
Time-BasedMediumTime windowNone
DataLoaderMediumGraphQL batchingdataloader
DebouncedHighSearch, autocompleteNone

🏢 Real-World Examples

Facebook

// DataLoader for GraphQL
// Batches friend requests
// Deduplicates profile fetches

Twitter

// Request pooling
// Dedupe timeline requests
// Batch avatar loads

Amazon

// Aggressive deduplication
// Product data batched
// Image requests pooled

📚 Key Takeaways

  1. Use React Query or SWR - Handles automatically
  2. Request pooling for custom solutions
  3. Time-based for repeated calls
  4. DataLoader for GraphQL batching
  5. Debounce for user input (search)
  6. Monitor duplicate requests in DevTools
  7. Test with multiple components

Deduplication is free performance. Always enable it.

On this page