skip to content

While a lazily loaded route's code is in flight, what decides whether the user keeps the previous screen or sees a fallback?

level: middleimportance: must knowfreq 60%

answer

  1. who owns the pending period
  2. hold the old screen or commit
  3. a pending cue versus a fallback
  4. fast loads can flash skeletons
  5. the nearest boundary is the one shown

basics

~10 s

Who owns the pending period. A router that holds the navigation keeps the old screen mounted with a progress cue; one that commits the new branch immediately shows the nearest async boundary's fallback instead.

solid answer

~50 s

Two placements of the same wait. If the router **holds the navigation**, the previous screen stays mounted and the app renders a pending cue: scroll position, open menus and half-typed text all survive, and a fast load shows almost nothing. If the router **commits immediately**, the new branch mounts and the region whose code is missing renders its nearest async boundary's fallback — honest about the destination, but it tears down the old screen at once and can flash a skeleton when the load is quick. Practical guidance: hold the old screen for short, likely-fast loads and for screens holding user work; show a fallback for slow or unpredictable loads, or when only one region of the new screen is waiting. Do not do both for one wait, and give the fallback a sibling failure path so a dead request is not a permanent skeleton.

go deeper

for a junior

Know that something must appear during the wait, and that the two options are keeping the old screen with a progress cue or showing a placeholder for the new one.

for a middle

Explain that the choice is where the pending period is handled, and name what each option costs: a flash and lost screen state versus an app that can look frozen.

for a senior

Demonstrate tuning it: a delay before the cue appears, a fallback sized to the real content, and both endings of the wait handled including the failed load.

for a principal

Talk about consistency across the app: one convention for how a navigation announces itself, so surfaces do not each invent their own loading behaviour.

## The same wait, two possible owners When a route entry names a module loader, something has to happen on screen between the click and the moment the route's code is in memory. Which of two things happens is not a property of lazy loading; it is a choice about **who owns the pending period**. - The **router** can own it: it starts the loader, keeps the current screen mounted, exposes a "navigation in progress" signal, and commits the new branch only once the module has resolved. - The **new branch** can own it: the router commits immediately, the route's component is not available, so the nearest **async boundary with a fallback** renders that fallback in place of the suspended subtree. Both are legitimate. They differ in what the user loses during the wait. ## Holding the previous screen The old screen stays mounted, so its scroll position, open menus, partially typed text and in-flight work all survive. Nothing flashes, because nothing is replaced until there is something real to replace it with. The cost is that the app **must** show the pending signal somewhere, or the click looks ignored: users who get no acknowledgement click again, which at best is a duplicate navigation and at worst starts a second one. Because most route bundles resolve quickly on a warm connection, many implementations reveal the pending signal only after a short delay — long enough that a fast load shows nothing at all, short enough that a slow one is acknowledged well before the user doubts the click. ## Committing and showing a fallback Here the new branch mounts at once, and the region whose code has not arrived renders its fallback: a skeleton, a spinner, a placeholder layout. The old screen is torn down immediately, so anything unsaved in it is gone. The benefit is honesty about the destination — the user sees the shape of where they are going, and any part of the new branch whose code *is* already present renders straight away rather than waiting for the slowest sibling. Two details decide how good this feels. The **nearest** enclosing boundary is the one that shows, so a fallback placed high in the tree blanks more of the screen than one placed just around the lazy route. And a fallback for a load that finishes inside a frame or two produces a visible flash: content, skeleton, content. | | Hold the previous screen | Commit and show a fallback | |---|---|---| | What the user sees first | the old screen plus a progress cue | the new layout with a placeholder region | | Old screen state | preserved until commit | discarded at once | | Risk on a fast load | none, if the cue is delayed | a one-frame flash of skeleton | | Risk on a slow load | the app looks stuck without a cue | a long stare at an empty shape | | Fits | short, likely-fast loads; forms and editors | slow or unpredictable loads; content screens | ## Choosing between them - Prefer holding the old screen when the current screen holds user work, when the load is usually fast, or when the two screens look similar enough that a skeleton adds no information. - Prefer a fallback when the wait is long or variable, when the old screen is now misleading (the user has already left it conceptually), or when part of the new branch can render immediately and only one region is waiting. - Do not do both for the same wait. A global progress bar *and* a full-page skeleton reads as two separate things going on. - Whichever you pick, the pending period needs a defined end in both directions: resolved, and failed. A fallback with no sibling failure path becomes a permanent skeleton the first time a bundle request fails. ## Traps worth naming - **The unacknowledged click.** Holding the old screen with no cue at all is the worst of the options, and it is also the default if nobody renders the pending signal. - **The skeleton flash.** Adding a fallback for a bundle that resolves in tens of milliseconds makes a fast app look busy. - **Layout jump.** A fallback whose height differs from the real content shifts the page when it swaps. - **Losing input.** Committing immediately unmounts the previous screen; if it held a half-finished form, the wait has cost the user data, not just time. - **Assuming there is a wait at all.** On the second visit the module is already resolved, so neither branch of this choice is exercised, which is why the pending path is easy to leave untested.

  • Why can showing a fallback feel worse than keeping the previous screen on a fast connection?
    Because a route bundle often resolves in tens of milliseconds. Committing immediately tears down content the user was reading and paints a placeholder for a frame or two, so the user sees content, skeleton, content. Holding the old screen and revealing the pending cue only after a short delay shows nothing at all when the load wins that race.
  • If the app holds the previous screen during the load, what must still change the instant the user clicks?
    Some acknowledgement: the activated link marked as pending, a progress indicator, a disabled repeat action. Without it the click reads as ignored, so users click again and the app either wastes work or starts a competing navigation. The cue can be delayed a little, but it cannot be absent.
  • What goes wrong if a fallback has no failure path beside it?
    A failed bundle request leaves the boundary in its fallback with nothing ever arriving, so the skeleton becomes permanent and looks like a hang rather than an error. The pending period needs both endings defined: resolved, and failed with a retry the user can see and act on.

saying these in an interview costs you the question

  • Assumes every lazily loaded route must show a loading fallback
  • Adds a skeleton for a load that usually finishes within a frame or two
  • Holds the previous screen with no progress cue at all, so the click looks ignored
  • Unmounts the current screen before the next route's code arrives, losing typed input
  • Shows a global progress bar and a full-page skeleton for the same wait
  • Leaves a fallback with no failure path, so a dead request becomes a permanent skeleton