Singleflight (Request Coalescing)
Singleflight prevents duplicate in-flight requests by coalescing concurrent calls with the same key into a single operation.
The Problem
Section titled “The Problem”When multiple parts of your application request the same data simultaneously, you get duplicate requests:
// Without singleflight - 3 network requests!const [user1, user2, user3] = await Promise.all([ fetchUser('1'), fetchUser('1'), // Duplicate fetchUser('1'), // Duplicate]);The Solution
Section titled “The Solution”With singleflight, concurrent calls with the same key share one in-flight request:
import { singleflight } from 'awaitly/singleflight';import { ok, err, type AsyncResult } from 'awaitly';
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { const user = await db.find(id); return user ? ok(user) : err('NOT_FOUND');};
// Wrap with singleflightconst fetchUserOnce = singleflight(fetchUser, { key: (id) => `user:${id}`,});
// Now only 1 network request!const [user1, user2, user3] = await Promise.all([ fetchUserOnce('1'), // Triggers fetch fetchUserOnce('1'), // Joins existing fetch fetchUserOnce('1'), // Joins existing fetch]);How It Works
Section titled “How It Works”- First caller with a key starts the operation
- Subsequent callers with the same key get the same Promise
- When operation completes, all callers receive the same Result
- Key is removed from in-flight tracking
Time →
Caller A: fetchUserOnce('1') ─────┬──────────────────┐ │ │Caller B: fetchUserOnce('1') ─────┤ (shares) ├─→ All get same Result │ │Caller C: fetchUserOnce('1') ─────┘ │ ↓ ↓ Start fetch CompleteUse Cases
Section titled “Use Cases”Prevent Thundering Herd
Section titled “Prevent Thundering Herd”When cache expires, many requests hit the backend simultaneously:
const getConfig = singleflight( () => fetchConfigFromAPI(), { key: () => 'config' });
// 100 concurrent requests → 1 API callawait Promise.all( Array.from({ length: 100 }, () => getConfig()));API Deduplication
Section titled “API Deduplication”Multiple components requesting the same data:
const fetchUserProfile = singleflight( (userId: string) => apiClient.getUser(userId), { key: (userId) => `profile:${userId}` });
// Sidebar, Header, and Content all request user// → Only 1 API callExpensive Operations
Section titled “Expensive Operations”Share computation across callers:
const computeReport = singleflight( (month: string) => generateExpensiveReport(month), { key: (month) => `report:${month}` });With TTL Caching
Section titled “With TTL Caching”Add TTL to cache successful results after completion:
const fetchUserCached = singleflight(fetchUser, { key: (id) => `user:${id}`, ttl: 5000, // Cache successful results for 5 seconds});
const user1 = await fetchUserCached('1'); // Fetchesconst user2 = await fetchUserCached('1'); // Returns cached (within TTL)
// After 5 seconds...const user3 = await fetchUserCached('1'); // Fetches againNote: TTL only caches successful results (ok). Errors are not cached.
Low-Level API
Section titled “Low-Level API”For more control, use createSingleflightGroup:
import { createSingleflightGroup } from 'awaitly/singleflight';
const group = createSingleflightGroup<User, 'NOT_FOUND'>();
// Execute with manual keyconst user = await group.execute('user:1', () => fetchUser('1'));
// Check if request is in-flightif (group.isInflight('user:1')) { console.log('Request pending');}
// Get number of in-flight requestsconsole.log('In-flight:', group.size());
// Clear all tracking (does not cancel operations)group.clear();API Reference
Section titled “API Reference”singleflight
Section titled “singleflight”singleflight<Args, T, E>( operation: (...args: Args) => AsyncResult<T, E>, options: { key: (...args: Args) => string; // Extract cache key ttl?: number; // Optional TTL in ms (default: 0) }): (...args: Args) => AsyncResult<T, E>createSingleflightGroup
Section titled “createSingleflightGroup”createSingleflightGroup<T, E>(): { execute: (key: string, operation: () => AsyncResult<T, E>) => AsyncResult<T, E>; isInflight: (key: string) => boolean; size: () => number; clear: () => void;}TTL Semantics Deep Dive
Section titled “TTL Semantics Deep Dive”Understanding how TTL works with singleflight:
TTL lifecycle
Section titled “TTL lifecycle”Time →
Request A: ────┬───────────────────┬──────────────────────────── │ In-flight │ Cached (TTL) │ │ │ │Request B: ────┤ (shares flight) ├──→ Returns cached │ │ │ │Request C: ────────────────────────┼──→ Returns cached │ │ │ │ ↓ ↓ ↓ Start Complete TTL expiresWhat gets cached
Section titled “What gets cached”const fetchUser = singleflight( async (id: string) => { const user = await db.find(id); return user ? ok(user) : err('NOT_FOUND'); }, { key: (id) => `user:${id}`, ttl: 5000 });
// ✅ Success cached for 5 secondsawait fetchUser('123'); // Fetches from DBawait fetchUser('123'); // Returns cached ok(user)
// ❌ Errors are NOT cachedawait fetchUser('999'); // Returns err('NOT_FOUND')await fetchUser('999'); // Fetches again (error wasn't cached)TTL vs in-flight deduplication
Section titled “TTL vs in-flight deduplication”// Without TTL: only dedupes concurrent requestsconst noTtl = singleflight(fetchUser, { key: (id) => `user:${id}` });
await noTtl('1'); // Fetchesawait noTtl('1'); // Fetches again (not concurrent)
// With TTL: dedupes + cachesconst withTtl = singleflight(fetchUser, { key: (id) => `user:${id}`, ttl: 5000 });
await withTtl('1'); // Fetchesawait withTtl('1'); // Returns cachedWhen TTL starts
Section titled “When TTL starts”TTL countdown begins after the operation completes, not when it starts:
const slowOp = singleflight( async () => { await sleep(10000); // Takes 10 seconds return ok(data); }, { key: () => 'slow', ttl: 5000 });
// t=0: Request startsawait slowOp();// t=10s: Request completes, TTL startsawait slowOp(); // Returns cached// t=15s: TTL expiresawait slowOp(); // Fetches againCache Invalidation
Section titled “Cache Invalidation”Manual invalidation with groups
Section titled “Manual invalidation with groups”import { createSingleflightGroup } from 'awaitly/singleflight';
const userCache = createSingleflightGroup<User, 'NOT_FOUND'>();
// Fetch with cachingconst getUser = (id: string) => userCache.execute(`user:${id}`, () => fetchUser(id));
// Invalidate single userconst invalidateUser = (id: string) => { userCache.clear(`user:${id}`);};
// Invalidate all usersconst invalidateAllUsers = () => { userCache.clear();};
// Example: Invalidate after updateconst updateUser = async (id: string, data: UpdateData) => { const result = await db.users.update(id, data); invalidateUser(id); // Clear cache return result;};Pattern: Write-through invalidation
Section titled “Pattern: Write-through invalidation”const userService = { cache: createSingleflightGroup<User, 'NOT_FOUND'>(),
get: async (id: string) => { return this.cache.execute(`user:${id}`, () => fetchUser(id)); },
update: async (id: string, data: UpdateData) => { const result = await db.users.update(id, data); if (result.ok) { // Invalidate cache on successful write this.cache.clear(`user:${id}`); } return result; },
delete: async (id: string) => { const result = await db.users.delete(id); if (result.ok) { this.cache.clear(`user:${id}`); } return result; },};Pattern: Prefix-based invalidation
Section titled “Pattern: Prefix-based invalidation”const cache = createSingleflightGroup<unknown, string>();
// Fetch with prefixed keysconst getUser = (id: string) => cache.execute(`user:${id}`, () => fetchUser(id));
const getUserOrders = (userId: string) => cache.execute(`user:${userId}:orders`, () => fetchOrders(userId));
// Invalidate all data for a userconst invalidateUserData = (userId: string) => { // Clear any key starting with user:${userId} const keys = cache.keys().filter(k => k.startsWith(`user:${userId}`)); keys.forEach(k => cache.clear(k));};Memory Considerations
Section titled “Memory Considerations”In-flight tracking is lightweight
Section titled “In-flight tracking is lightweight”Singleflight only stores:
- Key → Promise mapping for in-flight requests
- Key → Result mapping for TTL cache
// Memory footprint per key:// - In-flight: ~100 bytes (key string + Promise reference)// - Cached: depends on result size
const group = createSingleflightGroup();console.log(`Tracking ${group.size()} in-flight requests`);Prevent memory leaks with TTL
Section titled “Prevent memory leaks with TTL”// ❌ Without TTL, cached results grow unboundedconst noTtl = singleflight(fetchUser, { key: (id) => `user:${id}` });// (Actually, without TTL there's no caching, only in-flight deduplication)
// ✅ With TTL, cache auto-cleansconst withTtl = singleflight(fetchUser, { key: (id) => `user:${id}`, ttl: 60000, // 1 minute max});Monitor cache size
Section titled “Monitor cache size”const userCache = createSingleflightGroup<User, string>();
// Periodic monitoringsetInterval(() => { const metrics = { inFlight: userCache.size(), // If you track cached separately }; console.log('Cache metrics:', metrics);}, 30000);Large result handling
Section titled “Large result handling”// Be cautious with large results + long TTLconst fetchLargeReport = singleflight( () => generateMassiveReport(), // Returns 50MB of data { key: () => 'report', ttl: 300000, // 5 minutes - this keeps 50MB in memory! });
// Better: shorter TTL or no caching for large resultsconst fetchLargeReportSafe = singleflight( () => generateMassiveReport(), { key: () => 'report', ttl: 30000, // 30 seconds max });Comparison with Caching
Section titled “Comparison with Caching”| Feature | Singleflight | Cache |
|---|---|---|
| Dedupes in-flight requests | Yes | No |
| Stores results after completion | With TTL | Yes |
| Prevents thundering herd | Yes | Only with lock |
| Memory usage | Minimal | Depends on size |
Use singleflight when: You want to prevent duplicate concurrent requests.
Use caching when: You want to reuse results across time.
Use both when: You want both behaviors (singleflight with TTL option).
Common Patterns
Section titled “Common Patterns”Stale-while-revalidate
Section titled “Stale-while-revalidate”const cache = new Map<string, { value: User; timestamp: number }>();const group = createSingleflightGroup<User, 'NOT_FOUND'>();
const getUserSWR = async (id: string) => { const cached = cache.get(id); const isStale = cached && Date.now() - cached.timestamp > 60000; // 1 min
if (cached && !isStale) { return ok(cached.value); }
// If stale, return cached but refresh in background if (cached && isStale) { // Don't await - let it refresh in background group.execute(`user:${id}`, async () => { const result = await fetchUser(id); if (result.ok) { cache.set(id, { value: result.value, timestamp: Date.now() }); } return result; }); return ok(cached.value); // Return stale immediately }
// No cache - fetch and wait const result = await group.execute(`user:${id}`, () => fetchUser(id)); if (result.ok) { cache.set(id, { value: result.value, timestamp: Date.now() }); } return result;};Graceful degradation
Section titled “Graceful degradation”const cache = new Map<string, User>();
const getUserWithFallback = async (id: string) => { const result = await singleflightFetch(id);
if (result.ok) { cache.set(id, result.value); // Update cache return result; }
// On error, return stale cache if available const stale = cache.get(id); if (stale) { console.warn(`Returning stale data for user ${id}`); return ok(stale); }
return result; // No fallback available};