TL;DR
- Store the in-flight refresh as a shared
Promise(let inflight = null). - The first caller that sees expiry starts the refresh and assigns to
inflight. - Every other caller awaits
inflightinstead of starting its own refresh. - Clear in
finally— notthen— so a failed refresh doesn't wedge a stale rejected Promise. - Assign the Promise synchronously (before the first
await) so a second caller on the next microtask sees it.
The pattern with code
JavaScript's single-threaded event loop makes the check-and-set atomic: no other code runs between the if (!inflight) check and the inflight = ... assignment. That's why this works without any additional lock.
let inflight = null; // null when idle, Promise when a refresh is running let creds = await loadCreds(); // { access_token, refresh_token, expires_at } from your store const SKEW_MS = 60_000; // treat token as expired 60s early function isValid(c) { return c?.access_token && c.expires_at - SKEW_MS > Date.now(); } async function getValidToken() { if (isValid(creds)) return creds.access_token; // fast path — no refresh needed // SINGLE-FLIGHT: if a refresh is already running, await THAT one, not a new one if (!inflight) { inflight = doRefresh(creds) .then(next => { creds = next; return next; }) .finally(() => { inflight = null; }); // clear on both success AND failure } const refreshed = await inflight; // all concurrent callers wait here return refreshed.access_token; } async function doRefresh(prev) { const res = await fetch(TOKEN_URL, { method: "POST", body: form(prev.refresh_token) }); if (!res.ok) throw new Error(`refresh failed: ${res.status}`); const body = await res.json(); return await persistAtomic(mergeRotation(prev, body)); // see below }
With 100 concurrent callers hitting getValidToken() on an expired token, exactly one doRefresh runs. The other 99 await its Promise and get the result when it resolves.
Two details that are easy to get wrong
finally, not then. If you write .then(next => { inflight = null; return next; }), a rejected refresh leaves inflight pointing at a rejected Promise. Every caller that arrives after the rejection immediately re-rejects with the stale error. finally clears it on both success and failure so the next call tries a fresh refresh.
inflight synchronously before the first await. inflight = doRefresh(creds).then(...).finally(...) is all synchronous expression evaluation (the assignment happens before any microtask yields). A second caller arriving on the very next microtask will see inflight !== null and correctly awaits it. If you assigned inside an await, there would be a window where two callers both see inflight === null and both start a refresh.
Add rotation-merge (required for correctness)
Single-flight handles concurrency; rotation-merge handles persistence. Google's refresh response omits refresh_token entirely; if you overwrite your stored credentials with the response as-is, you lose the refresh token and force a re-consent. The rule: if the response omits refresh_token, keep the old one.
function mergeRotation(prev, res) {
const merged = { ...res };
if (!merged.refresh_token && prev?.refresh_token) {
merged.refresh_token = prev.refresh_token; // Google: keep the old one
}
if (merged.expires_in && !merged.expires_at) {
merged.expires_at = Date.now() + merged.expires_in * 1000;
}
return merged;
}
Proactive refresh reduces the race window
A SKEW_MS of 30–60 seconds treats the token as expired slightly before it actually is. This means callers hit the single-flight path before the real expiry cliff, so instead of 100 requests all discovering expiry at exactly t=0, they discover it at t=−60s — still all funneled through single-flight, but with more headroom for the refresh to complete before any real 401 is possible.
When single-flight is NOT enough
Single-flight only covers one process. If you have:
- Multiple CLI invocations running simultaneously against the same
~/.config/app/creds.json - A worker pool with several Node processes each reading a shared credential file
- Multiple containers on the same host with a mounted credential volume
… you need to also add a cross-process lock — an exclusive lock file (O_CREAT|O_EXCL) around the refresh-and-persist cycle, with a re-read of the token after acquiring the lock. Single-flight and a cross-process lock solve different problems; use both when credentials are shared across processes.
Using a library
The pattern is small enough to copy from this page. If you'd rather not own it, refresh-guard ships single-flight, rotation-merge, and atomic file persistence as a zero-dependency MIT primitive.
npm i refresh-guardimport { createTokenManager, fileStore } from "refresh-guard";
const tokens = createTokenManager({
store: fileStore("~/.myapp/creds.json"),
refresh: async (prev) => fetchNewToken(prev.refresh_token)
});
const access = await tokens.getValidToken(); // single-flight + rotation-merge built in
refresh-guard is by the same team as this guide. Source on GitHub · npm i refresh-guard.
FAQ
What is single-flight for concurrent token refresh?
The first caller to detect an expired access token starts the refresh and stores the in-flight Promise in a shared variable. Every other in-process caller awaits that same Promise. When the single refresh completes, all waiters receive the result. Exactly one refresh per expiry event, regardless of concurrency.
Why must I clear the in-flight Promise in a finally block?
If a refresh fails and you clear inflight only in the success branch (then), the rejected Promise remains wedged in inflight forever. Every subsequent caller immediately re-rejects with the stale error. finally runs on both success and failure, so the next call can start a fresh refresh.
Does single-flight fix concurrent refresh across multiple processes?
No. Single-flight only works within one process and event loop. Multiple OS processes each have their own inflight variable and cannot see each other's in-progress refresh. Add an inter-process lock for the multi-process case.
Is an async mutex library equivalent to a shared Promise?
Yes, for in-process concurrency. Any serializing primitive (mutex, semaphore, async-lock) that wraps the refresh-and-persist section achieves the same result. The shared Promise is just the lightest option — zero dependencies, leverages JavaScript's single-threaded event loop.
Is single-flight alone sufficient, or do I also need rotation-merge?
You need both. Single-flight prevents concurrent refreshes; rotation-merge prevents losing the refresh token on providers (like Google) that omit it from refresh responses. Without rotation-merge, you'll eventually overwrite the refresh token with undefined and force a re-consent.