Skip to main content

Overview

Request deduplication prevents multiple identical requests from being sent simultaneously. This is especially useful for:
  • Search-as-you-type functionality (cancel previous searches)
  • Configuration loading (share response across components)
  • Preventing double-submissions
  • Optimizing parallel component renders

Deduplication Strategies

Cancel Strategy

Cancels the previous request when a duplicate is detected. Perfect for search or autocomplete where only the latest request matters.
import { callApi } from "@zayne-labs/callapi";

// Search implementation
const handleSearch = async (query: string) => {
  try {
    const { data } = await callApi("/api/search", {
      method: "POST",
      body: { query },
      dedupeStrategy: "cancel",
      dedupeKey: "search" // All searches share the same key
    });
    updateResults(data);
  } catch (error) {
    if (error.name === "AbortError") {
      // Previous search was cancelled (expected)
      return;
    }
    console.error("Search failed:", error);
  }
};

// Typing "hello" quickly:
// Request 1: query="h"     → Cancelled
// Request 2: query="he"    → Cancelled
// Request 3: query="hel"   → Cancelled
// Request 4: query="hell"  → Cancelled
// Request 5: query="hello" → Completes ✓

Defer Strategy

Shares the same response promise between duplicate requests. Ideal for loading shared configuration or data that multiple components need.
import { callApi } from "@zayne-labs/callapi";

// Multiple components loading the same config
const loadConfig = () => callApi("/api/config", {
  dedupeStrategy: "defer",
  dedupeKey: "app-config"
});

// Parallel calls from different components:
const [result1, result2, result3] = await Promise.all([
  loadConfig(), // Actual fetch request
  loadConfig(), // Waits for request 1
  loadConfig()  // Waits for request 1
]);

// Only 1 HTTP request was made!
// All three get the same response
console.log(result1.data === result2.data); // true

None Strategy

Disables deduplication entirely. Every request executes independently.
const result = await callApi("/api/data", {
  dedupeStrategy: "none" // No deduplication
});

Configuration Options

dedupeStrategy

dedupeStrategy
'cancel' | 'defer' | 'none' | (context) => string
default:"cancel"
Strategy for handling duplicate requests. Can be static or dynamic based on request context.
dedupe.ts
type DedupeStrategyUnion = "cancel" | "defer" | "none";

type DedupeOptions = {
  dedupeStrategy?: 
    | DedupeStrategyUnion 
    | ((context: RequestContext) => DedupeStrategyUnion);
};
Static strategy:
const client = createFetchClient({
  baseURL: "https://api.example.com",
  dedupeStrategy: "defer" // All requests use defer
});
Dynamic strategy:
const client = createFetchClient({
  dedupeStrategy: (context) => {
    // Use defer for GET, cancel for everything else
    return context.options.method === "GET" ? "defer" : "cancel";
  }
});

dedupeKey

dedupeKey
string | (context) => string
default:"auto-generated"
Custom key generator for identifying duplicate requests. Defaults to a key based on URL, method, body, and stable headers.
dedupe.ts
type DedupeOptions = {
  dedupeKey?: string | ((context: RequestContext) => string | undefined);
};
Default behavior: The auto-generated key includes:
  • Full URL (including query parameters)
  • HTTP method
  • Request body
  • Stable headers (excludes Date, Authorization, User-Agent, etc.)
Custom static key:
// Singleton request - only one can run at a time
const config = await callApi("/api/config", {
  dedupeKey: "app-config",
  dedupeStrategy: "defer"
});
Custom dynamic key:
// User-specific deduplication
const dashboard = await callApi("/api/dashboard", {
  dedupeKey: (context) => {
    const userId = context.options.fullURL.match(/user\/(\d+)/)?.[1];
    return `dashboard-${userId}`;
  }
});

// Ignore volatile query parameters
const search = await callApi("/api/search?q=test&timestamp=123", {
  dedupeKey: (context) => {
    const url = new URL(context.options.fullURL);
    url.searchParams.delete("timestamp");
    return url.toString();
  }
});

dedupeCacheScope

dedupeCacheScope
'local' | 'global'
default:"local"
Controls whether deduplication cache is shared across all client instances or isolated per client.
dedupe.ts
type DedupeOptions = {
  dedupeCacheScope?: "global" | "local";
};
Local scope (default):
const userClient = createFetchClient({ baseURL: "/api/users" });
const postClient = createFetchClient({ baseURL: "/api/posts" });

// These clients don't share deduplication state
Global scope:
const userClient = createFetchClient({
  baseURL: "/api/users",
  dedupeCacheScope: "global"
});

const profileClient = createFetchClient({
  baseURL: "/api/profiles",
  dedupeCacheScope: "global"
});

// These clients share deduplication state

dedupeCacheScopeKey

dedupeCacheScopeKey
string | (context) => string
default:"default"
Namespace for global deduplication cache. Only relevant when dedupeCacheScope is "global".
dedupe.ts
type DedupeOptions = {
  dedupeCacheScopeKey?: "default" | string | ((context: RequestContext) => string | undefined);
};
Use cases:
// Group related services
const userClient = createFetchClient({
  baseURL: "/api/users",
  dedupeCacheScope: "global",
  dedupeCacheScopeKey: "user-service"
});

const profileClient = createFetchClient({
  baseURL: "/api/profiles",
  dedupeCacheScope: "global",
  dedupeCacheScopeKey: "user-service" // Shares cache with userClient
});

// Separate analytics
const analyticsClient = createFetchClient({
  baseURL: "/api/analytics",
  dedupeCacheScope: "global",
  dedupeCacheScopeKey: "analytics" // Different cache namespace
});

// Environment-specific
const apiClient = createFetchClient({
  dedupeCacheScope: "global",
  dedupeCacheScopeKey: `api-${process.env.NODE_ENV}`
});

Advanced Examples

Search-as-you-type

import { callApi } from "@zayne-labs/callapi";
import { debounce } from "lodash";

const searchUsers = debounce(async (query: string) => {
  try {
    const { data } = await callApi("/api/users/search", {
      method: "POST",
      body: { query },
      dedupeStrategy: "cancel",
      dedupeKey: "user-search",
      throwOnError: true
    });
    
    updateSearchResults(data);
  } catch (error) {
    if (error.name === "AbortError") {
      // Expected - previous search cancelled
      return;
    }
    handleSearchError(error);
  }
}, 300);

// In your input handler
input.addEventListener("input", (e) => {
  searchUsers(e.target.value);
});

Shared Configuration Loading

import { createFetchClient } from "@zayne-labs/callapi";

const configClient = createFetchClient({
  baseURL: "/api",
  dedupeStrategy: "defer",
  dedupeCacheScope: "global",
  dedupeCacheScopeKey: "app-config"
});

const loadAppConfig = () => configClient("/config", {
  dedupeKey: "app-config"
});

// Multiple components can call this simultaneously
// Only one request will be made
export const useAppConfig = () => {
  const [config, setConfig] = useState(null);
  
  useEffect(() => {
    loadAppConfig().then(result => setConfig(result.data));
  }, []);
  
  return config;
};

Conditional Deduplication

const client = createFetchClient({
  baseURL: "https://api.example.com",
  dedupeStrategy: (context) => {
    const { method, fullURL } = context.options;
    
    // Defer for config/reference data
    if (fullURL.includes("/config") || fullURL.includes("/reference")) {
      return "defer";
    }
    
    // Cancel for search endpoints
    if (fullURL.includes("/search")) {
      return "cancel";
    }
    
    // No deduplication for mutations
    if (method === "POST" || method === "PUT" || method === "DELETE") {
      return "none";
    }
    
    // Default to cancel for other GET requests
    return "cancel";
  }
});

User-Specific Deduplication

const { data } = await callApi("/api/users/:userId/profile", {
  params: { userId: "123" },
  dedupeStrategy: "defer",
  dedupeKey: (context) => {
    // Each user gets their own deduplication key
    const userId = context.request.url.match(/users\/(\w+)\/profile/)?.[1];
    return `user-profile-${userId}`;
  }
});

How It Works

Sequential Task Queue: CallApi uses a non-zero setTimeout delay to ensure requests are processed sequentially, allowing the cache to be checked and populated correctly even when multiple requests start simultaneously.
From the source code:
dedupe.ts
/**
 * Force sequential execution of parallel requests to enable proper cache-based deduplication.
 *
 * Problem: When Promise.all([callApi(url), callApi(url)]) executes, both requests
 * start synchronously and reach this point before either can populate the cache.
 *
 * Why setTimeout works:
 * - Each setTimeout creates a separate task in the task queue
 * - Tasks execute sequentially, not simultaneously
 * - Request 1's task runs first: checks cache (empty) → continues → populates cache
 * - Request 2's task runs after: checks cache (populated) → uses cached promise
 * - Deduplication succeeds
 *
 * IMPORTANT: The delay must be non-zero to avoid optimization batching.
 */
if (dedupeKey !== null) {
  await waitFor(0.001); // 1 microsecond delay for task queue scheduling
}

Best Practices

Use Descriptive Keys: When using custom deduplication keys, choose descriptive names that clearly indicate what’s being deduplicated:
dedupeKey: "user-profile-123"      // Good
dedupeKey: "cache-key-1"           // Bad
Avoid Volatile Data in Keys: Don’t include timestamps or random IDs in your deduplication keys:
// Bad - will never deduplicate
dedupeKey: `search-${Date.now()}`

// Good - deduplicates based on actual query
dedupeKey: `search-${query}`
Memory Considerations: Global scope creates separate caches for each scope key. Consider the number of different scope keys in your application to avoid memory issues.

Build docs developers (and LLMs) love