Everyone in dev land figured JWT auth was solved years ago. Tutorials everywhere preached the gospel of simple interceptors and localStorage checks. Smooth sailing, right?
Wrong. Dead wrong.
What we got instead? Token refresh stampedes. Multiple API calls hit at once, token’s expired, boom — five refreshes race to the server. Some win, some lose, users get mysteriously logged out. And it’s everywhere.
This changes things big time. No more half-assed flags or bloated queues. One dev drops a 40-line TypeScript snippet using a shared promise reference. Suddenly, your app acts like a pro. One refresh serves everyone. Skeptical? Read the code. It’s elegant as hell.
I’ve chased Silicon Valley hype for 20 years. Seen ‘secure auth’ SDKs balloon to megabytes while charging enterprise bucks (who’s making money? Those lib authors, that’s who). This? Pure open-source smarts. No deps. No BS.
Why Your Auth Code Is Secretly a Disaster
Picture this: Dashboard loads. Five components mount. All ping APIs. Token just expired — by seconds.
First caller refreshes. Succeeds.
But the next four? They’re piling on before the server rotates the refresh token. Three more 200s. Then two 401s because rotation kicked in. Interceptor spots 401s, queues retries. Loop city. Logout.
“This is a token refresh stampede. And almost every auth tutorial on the internet creates this exact bug.”
That’s the original post nailing it. Brutal truth.
Tutorials push this garbage:
// ❌ The version every tutorial teaches you
async function getAccessToken(): Promise<string | null> {
const token = localStorage.getItem("access_token");
if (token && !isExpired(token)) {
return token;
}
// Every concurrent caller hits this line simultaneously
const response = await fetch("/auth/refresh");
// ...
}
Five callers. All race. Stampede.
‘Fixes’ like boolean flags? Laughable. Set isRefreshing = true, and callers 2-5 get null. Retry hacks add jittery timeouts. Queues? Code bloat city.
Here’s the real deal. Forty lines. Production-ready.
"use client";
let cachedToken: string | null = null;
let tokenExpiresAt = 0;
let tokenPromise: Promise<string | null> | null = null;
// ... (fetch and parse functions)
export async function getAccessToken(): Promise<string | null> {
if (cachedToken && Date.now() < tokenExpiresAt) {
return cachedToken;
}
if (!tokenPromise) {
tokenPromise = fetchAccessToken()
.then((token) => {
cachedToken = token;
tokenExpiresAt = token ? parseTokenExpiry(token) : 0;
return token;
})
.finally(() => {
tokenPromise = null;
});
}
return tokenPromise;
}
Boom. Shared promise. First caller creates it. Others await the same one. One fetch. N happy consumers. .finally() resets for next time.
No jwt-decode lib (7KB waste). Parse expiry yourself: split dots, atob payload, grab exp, subtract 60s buffer. Fallback to 5min. Try-catch everything — real world ain’t perfect.
How Does a Shared Promise Actually Work?
Forget boolean flags. They’re dumb gates: one in, others starve.
Promise ref? Genius. It’s the result voucher. Everyone holds the ticket, waits for the prize.
if (!tokenPromise) {
// First: mint the promise
tokenPromise = fetchAccessToken().finally(() => tokenPromise = null);
}
return tokenPromise; // Everyone: same ticket
Concurrent callers sync up. No duplicates. Scales to 50 callers? Still one fetch.
My unique take: This echoes the ‘thundering herd’ wakeups in Unix kernels from the ’80s. Back then, processes stampeded on shared resources. Linus Torvalds griped about it in early Linux days. Solution? Smarter queuing. Here, promises are the modern mutex — lightweight, JS-native. VCs peddle $10M auth startups for this. Laughable.
Is This Better Than Axios Interceptors or Auth Libs?
Axios retries? Sure, re-queues originals. But multiple refreshes still fire if interceptors overlap.
Big libs like Auth0? Bloat. Telemetry. Lock-in. (Paywalls for devs? Pass.)
This? Zero deps. 40 lines. Works in React, Vue, Svelte — anywhere fetch lives. Add to your interceptor: call getAccessToken() on 401, retry original.
Buffer expiry early — 60s. Why? Network lag, clock skew. Servers hate last-second rushes.
Parse without libs: JWTs are base64 candy. token.split('.')[1], atob, JSON.parse. Done.
“Most apps use jwt-decode (7KB) or jsonwebtoken (200KB+ with deps) to read token expiry. You don’t need them.”
Preach.
Downsides? Singleton state — careful in SSR. Clear cache on logout. That’s it.
I’ve seen teams burn weeks on ‘auth flows’. This drops in hours. Who profits from complexity? Consultants. Lib vendors. Not you.
Roll it out. Test with 10 concurrent fetches (use Promise.all). Watch: one /refresh. Clean.
Why Does Token Refresh Stampede Hit Now?
SPAs exploded. React hooks fire APIs on mount. Users mash tabs. Tokens tuned tight — 15min expiry for ‘security’. Perfect storm.
Pre-SPA era? Monoliths serialized requests. No issue.
Prediction: This pattern spreads. Next? Shared promises for rate limits, feature flags. JS runtimes crave it.
Silicon Valley spins ‘zero-trust auth platforms’. Yawn. This is the fix — free, forever.
🧬 Related Insights
- Read more: SEO Audits: From LLM Waste to Tiered Genius
- Read more: The Docker Captain Making Six Figures While Teaching Everyone Else: How Sunny Built a Tech Career Beyond Code
Frequently Asked Questions
How do I implement token refresh stampede fix in React? Copy the getAccessToken function. Wire it to your Axios interceptor or fetch wrapper. Call on 401s, retry originals after.
What causes auth token refresh race conditions? Concurrent API calls when token expires. All hit refresh endpoint simultaneously. Server rotates refresh token mid-stampede — some fail.
Do I need libraries for JWT expiry parsing? Nope. atob + JSON.parse the payload. Subtract buffer. Fallbacks handle edge cases.