Request deduplication
typescriptA TypeScript utility that deduplicates concurrent requests by sharing a single in-flight promise among all callers requesting the same resource, preventing redundant network calls and improving application performance.
Code
type RequestFn<T> = () => Promise<T>;
interface DeduplicatorOptions {
maxCacheSize?: number;
onEvict?: (key: string) => void;
}
interface PendingRequest<T> {
promise: Promise<T>;
timestamp: number;
}
class RequestDeduplicator {
private pending: Map<string, PendingRequest<unknown>> = new Map();
private maxCacheSize: number;
private onEvict?: (key: string) => void;
constructor(options: DeduplicatorOptions = {}) {
this.maxCacheSize = options.maxCacheSize ?? 1000;
this.onEvict = options.onEvict;
}
async dedupe<T>(key: string, requestFn: RequestFn<T>): Promise<T> {
const existing = this.pending.get(key) as PendingRequest<T> | undefined;
if (existing) {
return existing.promise;
}
if (this.pending.size >= this.maxCacheSize) {
this.evictOldest();
}
const promise = this.executeRequest(key, requestFn);
this.pending.set(key, {
promise: promise as Promise<unknown>,
timestamp: Date.now()
});
return promise;
}
private async executeRequest<T>(key: string, requestFn: RequestFn<T>): Promise<T> {
try {
const result = await requestFn();
return result;
} finally {
this.pending.delete(key);
}
}
private evictOldest(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of this.pending.entries()) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp;
oldestKey = key;
}
}
if (oldestKey) {
this.pending.delete(oldestKey);
this.onEvict?.(oldestKey);
}
}
hasPending(key: string): boolean {
return this.pending.has(key);
}
getPendingCount(): number {
return this.pending.size;
}
clear(): void {
this.pending.clear();
}
}
const globalDeduplicator = new RequestDeduplicator();
function dedupeRequest<T>(key: string, requestFn: RequestFn<T>): Promise<T> {
return globalDeduplicator.dedupe(key, requestFn);
}
function createKeyedDeduplicator<TArgs extends unknown[], TResult>(
keyFn: (...args: TArgs) => string,
requestFn: (...args: TArgs) => Promise<TResult>,
deduplicator: RequestDeduplicator = globalDeduplicator
): (...args: TArgs) => Promise<TResult> {
return (...args: TArgs) => {
const key = keyFn(...args);
return deduplicator.dedupe(key, () => requestFn(...args));
};
}
async function fetchUser(userId: string): Promise<{ id: string; name: string }> {
console.log(`Fetching user ${userId}...`);
await new Promise(resolve => setTimeout(resolve, 100));
return { id: userId, name: `User ${userId}` };
}
const deduplicatedFetchUser = createKeyedDeduplicator(
(userId: string) => `user:${userId}`,
fetchUser
);
async function main() {
const results = await Promise.all([
deduplicatedFetchUser("123"),
deduplicatedFetchUser("123"),
deduplicatedFetchUser("456"),
deduplicatedFetchUser("123")
]);
console.log("Results:", results);
console.log("Notice: 'Fetching user 123...' only logged once!");
}
main();
export { RequestDeduplicator, dedupeRequest, createKeyedDeduplicator };How It Works
Request deduplication is a critical optimization pattern for applications that may trigger multiple identical requests simultaneously. This commonly occurs in React applications where multiple components mount and fetch the same data, or in event handlers that fire rapidly. Without deduplication, you waste bandwidth, increase server load, and may encounter race conditions.
The RequestDeduplicator class maintains a Map of pending promises keyed by a string identifier. When dedupe() is called, it first checks if a request with that key is already in flight. If so, it returns the existing promise, allowing multiple callers to await the same result. If not, it executes the request function and stores the promise. The finally block ensures cleanup happens regardless of success or failure, preventing memory leaks and allowing fresh requests after completion.
The implementation includes important production considerations. The maxCacheSize option prevents unbounded memory growth in scenarios with many unique keys. When the limit is reached, the oldest pending request entry is evicted (though its promise continues executing). The onEvict callback enables monitoring and debugging. Timestamps track when requests started, enabling intelligent eviction and potential timeout features.
The createKeyedDeduplicator helper function provides a more ergonomic API for common use cases. It wraps any async function and automatically generates cache keys from the arguments. This pattern is particularly useful for API client methods where you want deduplication built into the function signature rather than requiring callers to manage keys manually.
Use this pattern for read operations where identical concurrent requests should share results. Avoid it for write operations (POST, PUT, DELETE) where each call should execute independently, or when you need request-specific error handling that shouldn't be shared. For more complex caching needs including TTL-based expiration or result caching, consider combining this with a proper caching layer like lru-cache or a data fetching library like TanStack Query.