A guard reads a session that is still loading and bounces signed-in users to sign-in on reload; why, and what fixes it?
answer
- three states, not a boolean
- unknown is not anonymous
- await one shared session restore
- cold start has no previous screen
- replace the entry, never push
basics
~20 sThe guard collapses "not known yet" into "signed out". A session restored asynchronously is still unresolved during the first navigation after a reload, so the guard must await one shared resolution and model three states, not a boolean.
solid answer
~50 sSession state has three values, not two: unknown while it is being restored, authenticated, and anonymous. In-app navigations work because the session resolved long ago, but the first navigation after a reload or a pasted URL races the restore, and a boolean check reads the not-yet-known value as false and redirects. The fix is an asynchronous guard that awaits the session's resolution before it decides — one shared in-flight restore that every guard on the chain awaits, not one per route — and a neutral pending screen for that first navigation, because there is no previous screen for the router to hold. Redirect with a history replacement rather than a push so Back does not bounce the user between the guarded URL and sign-in, and record the intended destination only once the session has genuinely resolved to anonymous.
go deeper
Remember that a session being restored has a third state: not known yet. A guard must wait for it instead of reading it as signed out.
Explain the race on a cold start, why in-app navigation hides it, and how one awaited shared resolution plus a pending screen removes it. Know why replacing the history entry matters.
Cover the operational edges: a restore that hangs needs a deadline, superseded navigations must drop stale verdicts, and permission sets need the same three-state treatment as the session.
Decide once, app-wide, who owns session resolution and what every gated surface awaits, so guards, the shell and menus cannot disagree — and make sure the failure mode is a neutral screen, never a credential prompt to a signed-in user.
## The symptom and the cause The report is always the same shape: clicking around the app works, but reloading a protected page — or opening a link to one in a new tab — lands the user on sign-in, and a moment later they are signed in after all. Nothing is wrong with the session. What is wrong is that the guard asked a question whose answer had not arrived yet and treated the silence as a "no". Restoring a session is asynchronous work: reading a persisted credential, exchanging it for a fresh one, fetching the account and its permissions. On a cold start that work begins roughly when the app boots and the router's first match happens immediately, so the guard and the restore are in a race the guard usually wins. ## Three states, not a boolean | State | Meaning | Correct guard behaviour | |---|---|---| | **Unknown** | the restore has not finished | wait — decide nothing yet | | **Authenticated** | an identity and its permissions are known | apply the route's requirement | | **Anonymous** | the restore finished and there is no session | redirect to sign-in, carrying the intended target | A boolean has room for only two of these, so the third one has to be encoded somewhere — and the place it is usually encoded is "false", which is exactly the bug. An expired credential is *not* a fourth state: it resolves to anonymous once the attempt to refresh it fails. ## What an asynchronous guard must do 1. **Await a resolution, not a snapshot.** The guard reads a value that is already resolved or a promise-like handle it awaits. It never branches on the raw "unknown" value. 2. **Await one shared restore.** Several guards on a matched chain, plus the app shell, must join the same in-flight work. Each starting its own means duplicate network calls, a slower first screen, and verdicts that can disagree. 3. **Decide only on a resolved value**, then proceed, redirect to sign-in, or redirect to a forbidden screen. 4. **Abandon superseded work.** If the user navigates again while the guard waits, the stale verdict must be dropped rather than applied to the newer navigation. ## Where the pending UI lives On an in-app navigation the router already has a screen on the display, so the honest behaviour is to keep it and show a small pending indicator: the user stays where they were until the verdict arrives. On a cold start there is nothing to keep, so the app needs a deliberate neutral screen — the shell, a skeleton, a logo — for the interval before the first verdict. Two tempting alternatives are both wrong: - **Render the protected screen optimistically** and swap it if the guard rejects. That is the render-then-redirect leak with extra steps. - **Render the sign-in form** while the session is unknown. It is the most common version of this bug, and it is worse than a blank screen, because a user who is actually signed in is shown a login prompt and may type credentials into it. ## Traps around the same bug - **Capturing the wrong intent.** If the redirect fires under the unknown state, the destination recorded for after sign-in may be a route the user was only passing through, or sign-in itself. - **Pushing instead of replacing.** A pushed sign-in entry leaves the guarded URL one step back in history, so Back returns to it, the guard fires again, and the user ping-pongs. - **Loops through the sign-in route's own guard.** If sign-in is guarded by "only for anonymous visitors" and that guard also reads the unknown state, the two guards can redirect to each other. Guards must never redirect while the answer is unknown. - **A per-guard restore.** Duplicated restore calls can also produce a torn view: one guard sees anonymous, the next sees authenticated. - **Waiting forever.** If the restore can hang, the pending screen needs a deadline and a resolved outcome — treat a timed-out restore as anonymous and say so on the sign-in screen, rather than leaving a spinner up. ## The related mistake with permissions The same collapse happens one level in: a route needs a role, the account resolved but its permission set has not, and the guard reads an empty list as "no roles" and shows a forbidden screen to an administrator. Model the permission set with the same three states as the session, and let the guard await it too. ## What interviewers listen for - That you name the unknown state unprompted and refuse to fold it into false. - That you explain why in-app navigation hides the bug entirely. - That your pending design covers the cold start, where there is no previous screen. - That you mention replacing rather than pushing the sign-in entry, and the loop risk between two guards.
- Why does the bug show up only on reload or a pasted link?Because in-app navigations happen long after the session resolved, so the guard reads a settled value. A reload restarts the app: the restore begins at boot and the router's first match runs immediately, so the guard reads the unresolved value and redirects before the answer arrives.
- What should be on screen while an asynchronous guard waits on a cold start?A neutral pending screen — the shell or a skeleton — because there is no previous screen to keep. Not the protected content, which leaks and fetches, and not the sign-in form, which invites an already-signed-in user to re-enter credentials for no reason.
- What goes wrong if each guard starts its own session restore?Duplicate network calls on every cold start, a slower first meaningful screen, and verdicts that can disagree when one call resolves differently from another. Expose one shared resolution that every guard and the app shell await, and cancel it only when the app tears down.
Turning a guest away because the register has not been opened yet is not the same as finding their name missing from it.
saying these in an interview costs you the question
- Models the session as a boolean and redirects when it is false
- Shows the sign-in form while the session is still restoring
- Pushes the sign-in redirect, so Back bounces the user around
- Starts a separate session restore inside each guard
- Claims a reload clears the session, so the redirect is correct
- Reads an unloaded permission set as an empty set of roles