skip to content
luminary.blog
by Oz Akan
sun sketch

Single-Flight Pattern

How the single-flight pattern can prevent race conditions

/ 5 min read

Table of Contents

A traditional way to refresh the access token is to make UI smart enough that when it gets a 401 it will refresh the access token calling the backend.

I decided to refresh the access token in the backend instead of bothering the frontend. Initially it didn’t work because many UI components call the backend during page load, which, as you guessed, created a race condition.

The Problem

When multiple requests arrive with expired access tokens, only one out of three requests succeeds.

Timeline:
T=0ms: Request A arrives → Token expired → Start refresh with token "ABC123"
T=1ms: Request B arrives → Token expired → Start refresh with token "ABC123"
T=2ms: Request C arrives → Token expired → Start refresh with token "ABC123"
T=10ms: Request A's refresh completes successfully
- Gets new tokens: access="NEW_A", refresh="XYZ789"
- Old refresh token "ABC123" is now INVALID (rotated)
T=15ms: Request B's refresh attempts to use "ABC123"
❌ FAILS: "Invalid or expired refresh token"
T=18ms: Request C's refresh attempts to use "ABC123"
❌ FAILS: "Invalid or expired refresh token"

The Solution

I decided to create a new class named RefreshLock that uses a Map to track in-progress refresh operations:

class RefreshLock {
private locks: Map<string, Promise<AuthTokenResponse>> = new Map();
// ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
// key shared promise
}

The RefreshLock class prevents the race condition by implementing a “single-flight” pattern. All concurrent requests have the same old refresh token (“ABC123”) before any refresh completes. I use this token as the lock key.

Timeline:
T=0ms: Request A calls refreshLock.refresh("ABC123", refreshFn)
1. Check map: locks.get("ABC123") → undefined
2. Start refresh operation (call refreshFn)
3. Store promise: locks.set("ABC123", promise)
4. Return promise to Request A
T=1ms: Request B calls refreshLock.refresh("ABC123", refreshFn)
1. Check map: locks.get("ABC123") → EXISTS!
2. Log: "Refresh already in progress, waiting for result..."
3. Return SAME promise (no new refresh started)
4. Request B waits for Request A's refresh
T=2ms: Request C calls refreshLock.refresh("ABC123", refreshFn)
1. Check map: locks.get("ABC123") → EXISTS!
2. Return SAME promise
3. Request C waits for Request A's refresh
T=10ms: Request A's refresh completes
- Gets new tokens: access="NEW", refresh="XYZ789"
- Promise resolves with these tokens
- Cleanup: locks.delete("ABC123")
All three requests receive the SAME result simultaneously:
- Request A: Got new tokens from its refresh
- Request B: Got new tokens from Request A's refresh (waited)
- Request C: Got new tokens from Request A's refresh (waited)

Result: All 3 requests succeed with only 1 actual refresh operation. Less load on the database, responsive web page, happy customers.

Full Code

class RefreshLock {
private locks: Map<string, Promise<AuthTokenResponse>> = new Map();
async refresh(
refreshToken: RefreshToken,
refreshFn: () => Promise<AuthTokenResponse>
): Promise<AuthTokenResponse> {
const key = refreshToken;
const existing = this.locks.get(key);
if (existing) {
return existing;
}
const refreshPromise = refreshFn()
.finally(() => {
this.locks.delete(key);
});
this.locks.set(key, refreshPromise);
return refreshPromise;
}
}

How This Works

  1. Single-Flight Guarantee: Subsequent requests don’t start a new refresh; they wait for the in-progress one.
const existing = this.locks.get(key);
if (existing) return existing; // Return shared promise
  1. Shared Result: All waiting requests get the same Promise object, so when it resolves, they all receive the same new tokens.
  2. Token-Based Key: All concurrent requests with the same expired token are automatically grouped together.
const key = refreshToken;
  1. Automatic Cleanup: Whether the refresh succeeds or fails, the lock is removed so future refreshes aren’t blocked.
.finally(() => this.locks.delete(key))

Complete Flow Diagram

1. Request arrives
2. Middleware: Check access token
→ Expired/Missing
3. Check for refresh token
→ Found
4. RefreshLock: Check if refresh in progress
→ No → Start refresh
→ Yes → Wait for result
5. AuthService.refreshToken():
a. Hash refresh token
b. Lookup session
c. Validate session (not revoked/expired)
d. Get user roles
e. Rotate refresh token (old one invalid now)
f. Generate new access token JWT
g. Hash and save new refresh token
h. Return new tokens + user data
6. Middleware:
a. Format cookies with new tokens
b. Set Set-Cookie headers
c. Validate new access token
d. Set user context (userId, tenantId, etc.)
e. Continue to route handler
7. Route handler executes
8. Response sent with new cookies
9. Browser stores new cookies
10. Next request uses fresh access token

Key Security Considerations

There are a few things to keep in mind relevant to access and refresh tokens.

  1. HTTP-Only Cookies: Tokens never accessible via JavaScript (XSS protection)
  2. Token Rotation: Old refresh token invalidated immediately after use
  3. Hashed Storage: Refresh tokens hashed before storage (database breach protection)
  4. Single-Flight: Prevents race conditions from concurrent refreshes
  5. Session Validation: Checks revocation and expiration
  6. Short-Lived Access: 15 minutes forces frequent refresh
  7. SameSite=Strict: CSRF protection

Key Benefits of Single-Flight Pattern

While my use case was unique, the single-flight pattern fits many situations because it offers the benefits below:

  • Reduce Resource Usage: Prevents thundering herds by deduplicating expensive queries, API calls, or computations.
  • Lower Latency: Waiting requests piggyback on in-memory results instead of performing redundant work.
  • Prevent Race conditions: Ensures non-idempotent operations that must run once actually run once.

The Question

Always answer this question while you are building a system.

  • What happens if a hundred requests ask for the same resource at once?