AuthGENERALBROWSER-SPECIFICSIMULATED

Session Expiry and the Refresh Race

What the interface does when the credential dies mid-session: silent refresh, five simultaneous 401s that must produce one refresh, and the difference between expired and revoked.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

The credential stops working while the user is halfway through something — what should the interface do, and what must it not do five times at once?

The user intent

A person has a half-written comment, a filter set they spent a minute building, and a file uploading. They did not do anything wrong. Their session simply reached its end, and they expect the product to handle that without taking their work with it.

The obvious build

Catch a 401 anywhere in the app, redirect to the login page. It is one line in the fetch wrapper and it is obviously correct: the credential is gone, so the user must log in again.

Why it breaks

The half-written comment is gone. So are the filters, the scroll position, and the upload. A redirect is a navigation, and a navigation discards every piece of state that only existed in memory (The Seven Kinds of State).

How it breaks in a real browser
  • The half-written comment is gone. So are the filters, the scroll position, and the upload. A redirect is a navigation, and a navigation discards every piece of state that only existed in memory (The Seven Kinds of State).
  • A dashboard that fires five requests on mount gets five 401s within a few frames of each other. Five handlers run, five refreshes go out, and with rotating refresh tokens four of them are now invalid — so the successful refresh is immediately invalidated by its own siblings and the user is logged out by the fix.
  • It cannot tell "your token aged out, here is a fresh one" from "an administrator revoked your access". The first should be invisible; the second must not be silently repaired, and silently repairing it is exactly what a naive refresh loop attempts.
  • A background poll or a reconnecting socket triggers it with no user present, so the redirect happens while the user is reading, mid-scroll, for no reason they can perceive (Reconnect and Backoff).
  • If the refresh endpoint itself returns 401, the handler that reacts to 401 by refreshing calls it again. That is an unbounded loop against your own auth service, from every open tab at once (Retry Storms: The Load You Generated Yourself in Observability & Performance).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Expiry is not an event the browser delivers. There is no callback when a token ages out; the client discovers it by making a request and being refused. Everything else in this lesson follows from the fact that discovery is a failed request (The Life of a Fetch).
  • A silent refresh is a second credential exchanged for a first: the client presents a longer-lived refresh credential and receives a new access credential, then replays the request that failed. Done well, the user sees a slightly slower request and nothing else.
  • The refresh race. Requests are concurrent, so the first 401 is rarely alone. Without coordination, N failing requests produce N refreshes. The fix is a single-flight promise: the first caller starts the refresh and stores the promise; every later caller awaits the same promise instead of starting its own (Five Components, One Request).
  • The single-flight promise must be cleared on settle, not on success, or one failed refresh poisons every subsequent attempt for the life of the tab. This is the mitigation's own failure mode and it is easy to ship.
  • Expired is not revoked. Expiry is a lifetime elapsing and is expected, routine, and recoverable without the user. Revocation is a decision — logout elsewhere, a password change, an administrator action, a compromised session — and is not recoverable by presenting another credential. The server distinguishes them; the client must ask (Sessions in Security Engineering).
  • Proactive refresh — renewing shortly before a known expiry rather than after a failure — reduces the race but never removes it, because clocks drift, tabs are suspended, and the server may end a session early for reasons the client cannot predict (Long-Lived Clients and Version Skew).

What this makes the browser do

And which of it is avoidable.

  • Every queued request that is going to fail still costs a connection, a preflight where applicable, and a response. Five 401s are five round trips before any useful work happens (Reading a Network Waterfall).
  • Replaying requests after a successful refresh doubles those requests. With a request body that was consumed, the body has to be reconstructed rather than reused, which is a real constraint on how the retry is written.
  • A background tab is throttled: timers are slowed and network work may be deferred, so a proactive refresh scheduled with a timer can fire long after it was due — or after the session has already ended (Tasks: The Unit That Cannot Be Interrupted).
  • A service worker sitting between the page and the network sees the 401 too, and if it caches or retries independently it can produce a second, invisible copy of this entire problem (Intercepting Fetch).

Expired, revoked, forbidden: three different answers

Most broken expiry handling collapses three distinct server answers into one client behaviour. They arrive looking similar — a non-2xx status on a request the app expected to succeed — and they demand opposite responses. Getting this table right is most of the work.

The distinction the server owes you is between "this credential aged out" and "this session was ended". The first is recoverable without the user; the second must not be. If your API cannot tell you which, that is a contract gap worth closing, because the alternative is a client that silently re-authenticates sessions somebody deliberately terminated (The Error Model: Structure Over Apology in API Design).

What the client should do with each answer
TriggerSymptomCauseResponse
401, credential aged outA request the user expected to work fails; nothing else changedThe access credential reached the end of its lifetimeRefresh once through the single-flight gate, replay the request, show nothing. This is the invisible path.
401, session revokedThe refresh also fails, or the server says the session is goneLogout elsewhere, password change, administrator action, or detected compromiseStop. Clear identity, transition to expired with a returnTo, tell the user why, and do not attempt another refresh (Login Redirects and the Open-Redirect Trap).
403 on a specific actionOne button fails; everything else keeps workingValid credential, insufficient permissionDo not touch the session. Correct the affordance that offered the action and explain the refusal (Authorization-Aware UI).
401 from the refresh endpoint itselfA tight loop of refresh requestsThe 401 interceptor treats its own endpoint like any otherExclude the refresh endpoint from the interceptor. This is a one-line guard that prevents a self-inflicted outage.
Network failure, not a status codeRequests reject with no response at allOffline, DNS failure, or a dropped connection — not an auth problemSurface a connectivity state and retry with backoff. Never log the user out for it (Network Failures Only the Client Can See).
401 during a background poll with no user presentA logged-out screen appears while the user is reading something elseA background request driving a foreground navigationLet background failures update state quietly; require a user-facing action or an explicit prompt before navigating (Stale-While-Revalidate).

One refresh, however many failures

The race is the part that survives code review, because the code that causes it looks completely reasonable: an interceptor that catches 401 and refreshes. It is correct for one request and wrong for two, and a real page issues far more than two. The remedy is to make the refresh a resource that is shared rather than an action that is repeated.

Store the in-flight promise, not a boolean. A boolean tells later callers that a refresh is happening but gives them nothing to wait on, so they either proceed with a dead credential or poll. The promise is both the lock and the result (Futures & Promises in Concurrency & Parallelism).

Five requests, one expiry — with and without the gaterelative units (ordering only; not a measurement)
Five parallel requests on mount
Five 401 responses
Naive: five refreshes issued
Naive: logout the user did not ask for
Gated: one refresh; four callers await it
Gated: five replays with the new credential
Gated: UI settles, drafts intact
  • Five parallel requests on mountIssued together; the credential is already dead
  • Five 401 responsesArrive within the same handful of tasks
  • Naive: five refreshes issuedWith rotation, four invalidate the one that worked
  • Naive: logout the user did not ask forThe recovery path caused the failure
  • Gated: one refresh; four callers await itSingle-flight promise shared by every caller
  • Gated: five replays with the new credentialThe user sees one slower load and nothing else

The two paths do the same amount of useful work. The difference is entirely in how many refreshes were issued for one expiry, which is why the fix is a coordination fix and not a performance one.

Single-flight refresh with a bounded, non-looping retry
1let inFlight: Promise<void> | null = null
2
3function refreshOnce(): Promise<void> {
4 // Every caller that arrives while a refresh is running awaits the same one.
5 inFlight ??= fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
6 .then((res) => {
7 if (res.status === 401) throw new SessionEnded() // revoked: do not retry
8 if (!res.ok) throw new RefreshFailed() // transient: may retry later
9 return res.json().then(setAccessToken)
10 })
11 // finally, not then: a rejected promise left cached poisons every later call.
12 .finally(() => { inFlight = null })
13 return inFlight
14}
15
16export async function authedFetch(input: RequestInfo, init: RequestInit = {}) {
17 const res = await fetch(input, withCredentials(init))
18 if (res.status !== 401) return res // 403 included: not a session problem
19 if (isRefreshEndpoint(input)) return res // never refresh the refresh
20
21 try {
22 await refreshOnce()
23 } catch (err) {
24 identity.set(err instanceof SessionEnded
25 ? { status: 'expired', returnTo: currentPath() } // ask the user
26 : { status: 'expired', returnTo: currentPath() }) // same UI, different log
27 throw err
28 }
29 // Exactly one replay. A second 401 is an answer, not an invitation.
30 return fetch(input, withCredentials(init))
31}

Three details do the work: ??= makes the promise the lock, finally clears it on failure as well as success, and the replay is unconditional and singular. Everything else is bookkeeping.

The interface the user actually meets

When expiry cannot be handled invisibly, it becomes an interface, and it is one of the few interfaces that appears without the user asking for it. That makes the accessibility contract stricter rather than looser: something took over the page while the user was mid-task, and they must be told, must be able to act, and must be able to leave.

The strongest pattern keeps the page mounted. Show a dialog over the current view, re-authenticate inside it, dismiss it, and let the queued requests replay. The user's draft never left memory, the URL never changed, and no state had to be serialised anywhere (Form State Is a Draft).

accessibility specModal dialog raised by session expiry, over a page that stays mountedRe-authentication dialog

semantics A native dialog opened with showModal(), or role="dialog" with aria-modal="true"; named by aria-labelledby pointing at its heading; the expiry announcement itself in a polite live region that already exists in the DOM before the event (Live Regions and Announcement).

Tab / Shift+TabCycles within the dialog only; nothing behind it is reachable while it is open.
EscapeDismisses. The session stays expired and the user returns to a read-only page — never a dead end.
EnterSubmits the re-authentication form, exactly as a normal form would (Submission: Method, Encoding and Doing It Once).
Screen reader browse keysReach the dialog's heading and body; the page behind is hidden from the accessibility tree, not merely visually covered.
Focus
  • Move focus into the dialog on open — the heading, or the first field if there is exactly one obvious action.
  • Trap focus for as long as it is open: it is modal, and a Tab that escapes to the page behind puts the user somewhere they cannot act.
  • Return focus to the element that had it when the dialog closes, so the user resumes exactly where they were interrupted (Focus Management).
  • Do not steal focus for a warning that has not happened yet. A "your session ends soon" notice belongs in a live region, not in a dialog that seizes the caret mid-sentence.
  • If re-authentication ultimately fails and a navigation is required, move focus on arrival at the login view and change the document title (Login Redirects and the Open-Redirect Trap).
Announces
  • On expiry: a polite announcement — "Your session has ended. Sign in to continue." Assertive only if the user's next keystroke would otherwise be lost.
  • On the dialog opening: its accessible name and the first focused element, which is what the focus move buys you.
  • On a failed sign-in attempt inside the dialog: an error associated with the field and announced, not a red border (Errors People Can Actually Perceive).
  • On success: confirmation that the session resumed, and — if requests were replayed — that the view is up to date. Silence after a modal closes reads as failure.

usually broken by The pattern invites an undismissable dialog, on the reasoning that there is nothing useful to do while logged out. That traps keyboard and screen-reader users in a surface they cannot leave, hides content they may still legitimately read, and turns a recoverable interruption into a forced reload — which is precisely the navigation that destroys the work this dialog existed to protect.

How to build it

Most important first.

  • Deduplicate the refresh with a single-flight promise held in one module, cleared in a finally. This is the single highest-value line of code in the lesson (Single-Flight Coalescing in Concurrency & Parallelism).
  • Never navigate away on expiry if you can help it. Show an in-place re-authentication surface, keep the page mounted, and resume — the user's draft is in memory and a navigation is what destroys it (Form State Is a Draft).
  • Distinguish the causes from the server's answer and behave differently: expired means refresh once; revoked means stop, clear identity, and tell the user why; 403 means the credential is fine and this action is not permitted, so do not touch the session at all.
  • Bound the recovery. One refresh attempt per failure, no refresh attempt on the refresh endpoint itself, and a hard stop that transitions to expired and asks the user rather than looping (Retries, and the Duplicate Order).
  • Queue the requests that failed rather than discarding them, replay them after the refresh resolves, and cancel them if the user gives up. The failed request and its retry should be one operation from the UI's point of view (Cancelling a Request Nobody Is Waiting For).
  • Make destructive actions idempotent or key them, so a replayed request cannot double-charge or double-send. A retry after a refresh is a retry like any other (Idempotency Keys: The Mechanism in API Design).
  • Preserve where the user was, so that if re-authentication does require a navigation, coming back lands on the same view with the same URL state (Login Redirects and the Open-Redirect Trap).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Expiry must be announced, not merely rendered. A banner that appears with no live region is invisible to a screen-reader user, who then experiences the product as silently broken (Live Regions and Announcement).
  • Use a polite live region for "your session is about to end" and an assertive one only for something that interrupts what the user is doing right now. Assertive announcements cut off whatever was being read and are the wrong default.
  • A re-authentication dialog is a modal dialog and owes the full contract: role="dialog" with aria-modal="true", an accessible name, focus moved into it on open, focus trapped while it is open, Escape to dismiss, and focus returned to the element that had it (Accessible Component Patterns).
  • "Dismissible" is not optional. A dialog with no keyboard exit traps the user in a state they cannot leave, and if it appeared while they were mid-task it has also taken away their ability to read the page behind it.
  • If expiry does force a navigation to a login page, move focus to the login heading or the first field on arrival, and make sure the page title changes so the transition is announced (Focus Management).
  • Never rely on a countdown alone. Someone using a screen reader, a switch device or voice control may need far longer to act on the warning than a visual timer allows; provide an explicit "stay signed in" control with no time pressure (Contrast, Colour and Motion).

What can go wrong

Failure modes
  • The refresh stampede: N concurrent 401s produce N refreshes, and rotating refresh credentials turn that into a logout. Multiply by open tabs (Auth Across Tabs).
  • The single-flight promise cached on success only, so one network blip leaves a rejected promise stored forever and every future request awaits a failure.
  • Infinite retry between the app and the auth service when the refresh endpoint answers 401, hammering your own infrastructure from every client simultaneously.
  • A replayed non-idempotent request after refresh: the first attempt reached the server and did the work before the token check, and the retry does it again.
  • Treating 403 as expiry: the app refreshes a perfectly valid session, replays, gets 403 again, and loops until the bound stops it — while the actual problem was permissions.
  • A revoked session silently repaired by a refresh credential that was not itself revoked. The user logged out on their phone and their laptop stayed in.
What can arrive out of order
  • N concurrent requests fail with 401 within the same task or the next few. Without a single-flight promise this is N refreshes; with rotation, N-1 of them poison the one that worked.
  • A refresh completing while a request that started before it is still in flight with the old credential. That request may fail after the refresh succeeded, and retrying it once is correct — retrying it into another refresh is not.
  • A logout in another tab landing between this tab's 401 and its refresh, so the refresh succeeds against a session the user has already ended (Auth Across Tabs).
  • A proactive refresh timer firing at the same moment a reactive refresh starts from a 401, producing exactly the duplicate the timer was meant to prevent — both paths must go through the same single-flight gate.
Security
  • Refreshing is an authentication event and the server decides it. The client presenting a refresh credential is a request, not a right, and the server may refuse for reasons the client cannot see (Authentication in a Backend in Backend Engineering).
  • The gap between revocation and the client noticing is bounded by the access credential's lifetime, which is the practical argument for short lifetimes: it is how quickly a "log out everywhere" actually takes effect (Short-Lived Credentials in Security Engineering).
  • Rotating the refresh credential on every use lets the server detect replay — if an old refresh credential is presented again, something has a copy. That server-side property is precisely what a client-side stampede destroys by presenting several at once (Replay Attacks in Security Engineering).
  • Never make the client the authority on expiry. Reading a token's expiry claim to schedule a proactive refresh is fine; treating it as permission to continue is not, because it is a value the user can edit (JWT Failure Modes in Security Engineering).
  • Expiry warnings must not leak session content. A dialog that helpfully lists what the user was doing is rendered in a page that may be shoulder-surfed on a shared screen.
Misreads
  • "401 means log out." 401 means this request had no valid credential. Whether that implies a logout is a separate question you have to answer.
  • "403 means the session expired." 403 means the credential was accepted and the action is not permitted. Refreshing it changes nothing (Status Codes Clients Can Branch On in API Design).
  • "The token has an expiry claim, so I can just check it." You can use it to schedule a refresh. You cannot use it to decide the session is still valid, because the server may have ended it early and the value is client-side data.
  • "We refresh on 401, so expiry is handled." Handled is: one refresh for N failures, no loop when refresh itself fails, replay that is safe, revocation distinguished from expiry, and the user's draft still there afterwards.
  • "Proactive refresh removes the race." It reduces how often you hit it. Suspended tabs, clock skew and server-side termination all still produce a surprise 401.

Measuring it, and what changes in the field

How you would see this
  • Network panel filtered to the auth endpoints: one refresh per expiry is correct, several within the same second is the race, and a repeating pattern is the loop (Debugging the Network).
  • Count refresh requests per session in the field. The ratio of refreshes to sessions should be close to the number of expiries, not a multiple of it (Real User Monitoring).
  • Track 401 rate and unexpected-logout rate as separate signals. A rising logout rate with a flat 401 rate usually means the recovery path broke, not the credential (Frontend Error Tracking).
  • Session replay, with credentials and form contents redacted, shows what the user lost when the session ended — which is the part no metric captures (Session Replay and the Privacy It Costs).
Slow device, slow network, large data, old tab
  • A tab left open overnight wakes with a certainly-dead credential and often several stale requests scheduled to fire immediately, which is the worst case for the race.
  • On a slow network, the refresh takes long enough that more requests pile up behind it — so the deduplicating queue matters more, not less, exactly when it is hardest to test.
  • On mobile, tabs are frozen and discarded aggressively; a proactive refresh timer is not a guarantee that anything ran (Long-Lived Clients and Version Skew).
  • With several tabs, every tab races every other tab as well as itself, and shared storage means one tab's rotation can invalidate another's in-flight credential (Auth Across Tabs).
  • Offline, a 401 and a network failure look similar from a catch block and mean opposite things. Distinguish them or you will log people out for going through a tunnel (Network Failures Only the Client Can See).
What this costs
  • Silent refresh keeps the user working and hides an authentication event from them entirely — which is the intent, and also means a compromised refresh credential produces a session that never visibly ends.
  • Short access-credential lifetimes shorten the revocation window and increase refresh traffic and race frequency. The trade is real and the right point on it depends on what a stolen session can do.
  • In-place re-authentication preserves the user's work and costs a modal with a full accessibility contract, plus a page that must be safe to leave mounted while unauthenticated (Authorization-Aware UI).
  • Queuing and replaying failed requests is more code than failing them, and every replay needs an idempotency story. Failing them is simpler and loses work.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe discovery model — you learn the credential is dead by being refused — is true of every browser and every credential format, because no browser delivers an expiry event. The single-flight remedy follows from concurrency, not from any implementation detail.
  • BROWSER-SPECIFICHow aggressively a background or hidden tab is throttled, frozen or discarded differs by browser and by platform, and mobile browsers are far more aggressive than desktop ones. A proactive refresh timer is therefore a best-effort optimisation everywhere and much weaker on phones than the code implies.
  • SIMULATEDThe timeline in this lesson is an Engineer Atlas model of ordering, not a measurement. It shows which events overlap and in what sequence; the spans are relative units chosen for legibility and no duration should be read from them.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Performanceretry-storms
Domains that do not exist yet
  • Testing & Reliability Engineering — the refresh race needs a test that issues several requests against an already-expired credential and asserts that exactly one refresh was made. It is trivial to write and almost never present.