Skip to content

A loading() skeleton delays content by up to 300 ms on a cold click; an idle view transition adds about 250 ms under transition() #1078

Closed

Description

@ivogt

A plain click (nothing prefetched) on a route with loading() shows its content up to 300 ms later than the same route without loading(), and a route with transition() and loading() can lose another 250 ms to a view transition that changes nothing. Both are measured on main (0b498e15), in dev and in a production build.

Measurements

Throwaway fixture in e2e/test-app: one loader whose duration comes from the query, read with useLoader by a client component; links with prefetch="none"; 5 clicks per cell on fresh pages; medians, ms after the click; "complete" is the loader's value on screen. Dev is within 10 ms of production in every cell.

1. loading() and a fast loader (no transition())

Loader takes loader only (React holds the previous page) loader + loading() Skeleton on screen
0 ms 22 318 300 ms
100 ms 109 319 301 ms
200 ms 211 318 301 ms
280 ms 290 318 301 ms
320 ms 331 329 312 ms
500 ms 511 510 492 ms

With a 0 ms loader the whole response had arrived 16 ms after the click, the skeleton appeared at 18 ms and stayed until 318 ms. With 100 ms added to the network the picture is the same, shifted: 412 against 211 for a 100 ms loader.

2. transition() + loading()

Loader takes transition() transition({ viewTransition: false }) View transitions started
100 ms 366 320 2
280 ms 368 323 2
400 ms 664 411 3
500 ms 666 510 3

At 400 ms the three transitions run 39 to 314, 347 to 631 and 633 to 914 ms. The middle one changes nothing on screen; the content is applied by the third, which cannot start before the second has finished. The loader's value was in the browser at 406 ms.

Two loaders on one route (100 ms and 400 ms), read under the same loading(): 3 transitions, complete at 665 ms against 412 ms with viewTransition: false.

3. A loading() on a layout and another on the route below it (100 ms network delay)

Loader takes Fallbacks shown Complete
280 ms outer 412
320 ms outer, then inner 712

Why

  • React does not replace a fallback sooner than 300 ms after the last fallback was committed (react-dom-client.production.js:12183-12187, React 19.3.0: a retry waits for globalMostRecentFallbackTime + 300).
  • A cold navigation that mounts new segments is committed urgently on the response's first chunk, "so fallbacks stream like a first load and the click has visible feedback" (src/browser/partial-update.ts:773). The skeleton is committed even when the rest of the response is a few milliseconds behind, and from then on the content waits out the 300 ms.
  • Under transition() the router wraps the segment in <ViewTransition> (src/segment-system.tsx:523-561), and React holds a new view-transition commit until the running transition's finished resolves (react-dom-client.production.js:12282-12294). When the loader is slower than the throttle, something retries the boundary at about 300 ms, the render suspends again, and React still runs a transition for that commit (about 280 ms). The content then waits for it. What triggers that retry is not identified yet: in another fixture the same route shape started two transitions in production and three in dev.
  • With two boundaries, the outer reveal at 300 ms finds the inner data missing, the inner fallback is committed and the 300 ms starts again.

What an app sees

  • A route with loading() and a loader faster than 300 ms always shows its skeleton for 300 ms. Removing loading() makes the same click complete up to 300 ms sooner.
  • A route with transition(), loading() and a loader slower than about 320 ms shows its content about 250 ms after the data arrived.
  • Fully prefetched clicks are not affected (they commit complete). Cold clicks, hard-to-prefetch routes and anything that streams are.

Options, none decided

  1. Do not commit the skeleton at once. Commit a cold navigation inside a transition and fall back to the urgent commit only if the page is still incomplete after a short wait. Even a wait of a few tens of milliseconds removes the rows above where the data was already in the response. Cost: the first visual change comes later by that wait, with useNavigation() as the feedback meanwhile. Prior art: TanStack Router waits before showing its pending component ("By default, TanStack Router will show a pending component for loaders that take longer than 1 second to resolve", defaultPendingMs) and then keeps it for a minimum time ("will show your pending component for at least 500ms", defaultPendingMinMs).
  2. No view transition for a commit that leaves the fallback in place. Needs the trigger of the idle retry identified first; then either the boundary does not animate that commit or the retry is avoided.
  3. Nested loading(): reveal the outer boundary together with the inner one when the inner data is close, instead of committing a second fallback.

This changes what every route with loading() does on a cold click, so it needs a design and a decision on the default before any code.

Related

prefetch: false (in review) completes a prefetched page with a second request and times its reveal to match what a plain click does today, including the 300 ms above. If the plain click's schedule changes here, that code and its parity tests must follow.

Activity

  1. ivogt commented on Oct 7, 2026

    @ivogt
    CollaboratorAuthor

    Two more measurements on main (0b498e15, production; dev within 10 ms), same method.

    A route with loading() and no loader at all pays the same 300 ms. The handler returns at once and the response is complete a few milliseconds after the click:

    Click (nothing prefetched) Skeleton on screen Complete
    hub to a route with loading() and no loader 302 ms 318 ms
    hub to a layout with loading() and no loader, child route 301 ms 318 ms
    child a to child b under that layout (layout already on screen) none 19 ms
    the first two, fully prefetched none 12 to 15 ms

    So the cost does not need a loader, only a boundary that is new to the page and a commit on the first chunk. It is the same after other such boundaries have rendered, so it is not a first-read effect of the shared empty loader promise.

    An app-placed <ViewTransition> with no transition() on the route: a plain click shows the skeleton for about 300 ms and starts its view transition when the content replaces it (two transitions with a 500 ms loader when the boundary is in a layout: 319 to 595 and 634 to 929, complete at 646). A fully prefetched click starts one transition at the click and is complete at about 30 ms. The prefetched click starts no transition the plain click lacks.

  2. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Status (2026-10-08): the trigger of the idle middle view transition is now being investigated on main (local experiment, nothing pushed): one timeline of every update and commit for the 400 ms cell, the update that starts the idle transition named with its source, then three candidates measured: removing or reshaping that trigger, not animating a commit that leaves the fallback in place, and a short wait before the first commit (option 1 above). Results will be posted here.

    Related work is tracked in #1079 (a boundary handed a promise React has not read; the guard that keeps it from coming back). Draft #1080 removes the "no loader at all" row above in a production build.

  3. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    The trigger of the idle view transition is identified, and four ways to remove the delay are measured (local experiment branch, a draft PR with the fixtures and candidates follows). No router update is involved: it is React committing its own Suspense retry.

    Timeline of the 400 ms cell

    Dev, instrumented react-dom dev build, ms after the click, all measured.

    At What happens
    38 The navigation commits in a transition. The render suspends on the route's client component reference, a Flight lazy whose module is still loading. Transition 1 starts (type navigation).
    57 The reference has loaded. React schedules a retry of the route's boundary (RouteContentWrapper, src/route-content-wrapper.tsx:107).
    59 The retry renders and suspends again, now on the loader read (src/use-loader.tsx:220). It completes with the fallback still in place, and its commit is throttled to 300 ms after the fallback.
    356 That commit lands. A retry commit is view-transition eligible and the router's <ViewTransition> is on its path, so React starts transition 2. Nothing in the DOM changes. React cancels its groups, but the browser's default 250 ms animation keeps running until 640.
    410 The loader resolves. Its reveal has to wait for transition 2.
    640 Transition 3 reveals the content (in the DOM at 657).

    With a 100 or 280 ms loader the loader resolves while the retry's commit is still throttled, so that commit is the reveal and there is no idle one.

    Production is narrower than the table at the top suggests. The idle transition needs the client reference to be still loading in that render. With the route's client component module already loaded, a production build starts 2 transitions and completes at 416 ms (5 of 5). The numbers above were taken on a fresh page per click, which is the first navigation to that module. In dev every navigation has it, because the dev server tags client references per render. Two loaders under one loading() do not depend on that: the retry after the first loader is the same kind of commit (fallback stays in place).

    Candidates

    Complete, median ms, production (dev within 16 ms), 5 runs per cell, ranges within 48 ms of the median, load average 3 to 11.

    100 ms 280 ms 400 ms 500 ms Loaders of 100 and 400 ms 400 ms, fallback is the app's own <Suspense> Transitions at 400 ms
    main 367 366 664 665 653 664 3
    a: client references settle before the first commit (50 ms cap) 381 381 415 514 666 not run 2
    b1: a commit with no transition type does not animate a router boundary 349 349 408 509 409 407 1
    b2: the router's fallback sits in its own <ViewTransition update="none"> 364 365 415 515 415 665 2
    d: a view transition in which React cancelled every group is skipped 365 365 415 515 415 415 3 (the idle one lasts 17 ms)
    c: the first commit waits 30 ms 398 398 415 517 695 not run 2

    What each changes besides the delay:

    • a: the fallback appears about 15 ms later. Does not help with two loaders.
    • b1: the reveal of the content no longer animates, nor does any other untyped update under a router boundary. The navigation itself still animates.
    • b2: the reveal becomes a fallback exit plus the content entering. An app-placed <Suspense> is not covered. same-route-nav.test.ts:230 failed more often with it in dev under load (10 of 14 against 7 of 21 with the candidates off; that test is a known race on main), so it is not cleared.
    • d: the reveal animates as on main. It wraps document.startViewTransition and reads the animations React uses to cancel groups; neither is a contract. Suites that pin transitions and held navigations: dev 89 passed, production 63 passed.
    • c: the first paint is later by the wait; a 100 ms wait changes other cells (a 100 ms loader completes at 117 with no fallback, a 280 ms one at 466).

    Routes a fix should not touch (loading() without transition(), transition() without loading()) stay within 9 ms under a, b1, b2 and d.

    Not determined

    • Whether a newer React ends a fully cancelled transition by itself.
    • How b2's reveal looks on screen (only the animation list was recorded).
    • Anything outside Chromium.
  4. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    The fixtures, recorders and the four candidates behind a switch are in draft #1084 (a measurement branch, not for merge as it is). Its bundle guard fails as expected: with all four candidates in the client build the router chunk is 48,449 B gzip against the 48,128 B limit; the per-candidate cost is not measured yet.

  5. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Two more facts for choosing a candidate.

    Client bytes of each candidate alone (origin/main plus that candidate, cloudflare-basic client router chunk, gzip; limit 48,128, main 47,488):

    Candidate Behind the switch As a real fix (no switch)
    a: client references settle first +319 not built
    b1: no animation for a commit with no transition type, under a router boundary +125 +44
    b2: the fallback in its own <ViewTransition update="none"> +222 not built
    c: wait 30 ms before the first commit +181 not built
    d: skip a view transition React cancelled entirely +407 +341

    All fit on their own; typecheck passes on both unconditional variants.

    Newer React does not fix it. Checked 19.3.0, canary (19.3.0-canary-d75b0697-20261006) and experimental (0.0.0-experimental-d75b0697-20261006). When every group of a view transition is cancelled, React applies zero-duration animations to each group, to ::view-transition-group(root) and to ::view-transition (19.3.0 react-dom-client.production.js:11011-11054), and still calls document.startViewTransition (:15733); its ready handler calls no skipTransition(). skipTransition() is only called when more work is flushed (:13223). startViewTransition is textually identical in canary. No issue or PR about it found in React's repository.

    So candidate d would stay in place until React changes this, and costs 341 B of the 640 B left under the limit; b1 costs 44 B and relies on nothing internal, but a streamed reveal under a router boundary no longer animates.

  6. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    One more measurement for the choice between b1 and d, on a page built for the maintainer to try (production, route with transition(), loading() and two loaders of 100 and 400 ms under it, read by two client components): main 3 view transitions, content at 649 to 681 ms; b1 1 transition, 407 to 412 ms; d 3 transitions, 664 to 711 ms: d does not help there. On that page React animates the route's group in the middle transition (a CSS animation on ::view-transition-group(_t_0_)) instead of cancelling it, so d's check for a transition React cancelled entirely does not match, while nothing new is on screen. On the measurement route with loaders of 400 and 700 ms, d still ends the middle transitions (content 722 against 922 ms). So d covers only some shapes; b1 covered every shape measured.

  7. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    b1 is built (local branch fix/vt-idle-transition) and is on hold. In production it does what it should: a click starts one view transition instead of three, and the content lands at 413 to 419 ms against 724 and 743 ms on main. In dev, an action result or a router.refresh() that stays on the same route no longer animates. On main it does, and e2e/transition-when-browser.test.ts:255 fails in dev with b1.

    Cause, measured with a patched react-dom dev bundle:

    • React hands the queued transition types to the first commit whose lanes are all transition or retry lanes, then clears them (claimQueuedTransitionTypes, react-dom-client.development.js:6015, called at :19546).
    • The router's hooks each schedule their own transition when notified (use-navigation.ts:94, use-handle.ts:125, use-link-status.ts:125, use-action.ts:194).
    • In dev, one of those hook transitions commits first: an empty commit (lane 512) that takes the "action" type. The router's tree commit (lane 256) then has no type, so b1 gives it the class "none".
    • Production animates only because the router's commit happens to land first.
    • Turning StrictMode off changes nothing.

    b1 depends on the type reaching the router's commit, so the maintainer chose to fix this at its source: the hook updates commit together with the router's tree. That also satisfies the one-commit-per-navigation rule in #1079. A design pass is running (measure every commit per navigation and action in both modes, choose how the hooks join the router's commit, write the red tests). b1 waits for it.

  8. ivogt commented on Oct 11, 2026

    @ivogt
    CollaboratorAuthor

    Decision (2026-10-11): all three parts are accepted as React's behaviour and documented, with escape hatches. There is no router change, and the #1084 candidates won't be built.

    Parts 1 and 3 (a loading() skeleton held up to 300 ms on a cold click; a nested loading() showing a second fallback): this is React's own 300 ms Suspense throttle. Holding the click so the skeleton shows later (option 1), or revealing the outer boundary together with the inner one (option 3), would trade an early skeleton for a wait. We prefer the early skeleton.

    Part 2 (the idle view transition under transition() + loading()):

    • Cause: a Suspense retry commit that leaves the loading() fallback in place is eligible for a view transition, so React calls document.startViewTransition through the router's <ViewTransition>. React cancels its groups, but the browser's default animation still runs (265 to 285 ms from ready to finished). The loader's reveal waits until it finishes.
    • When it happens: a loader slower than about 320 ms while the route's client module is still loading, which means the first production navigation to that module and every dev navigation. Or two loaders under one loading() whose last lands later than about 320 ms.

    Measured on main (a7a211f) in Chromium, 5 fresh pages per cell. Each cell is document.startViewTransition calls / ms until the 400 ms loader's value is on screen. Each cell gives two results: one 400 ms loader, then loaders of 100 and 400 ms.

    transition() Production Dev
    default 3 / 649; 3 / 665 3 / 666; 3 / 680
    { when: () => false } 0 / 408; 0 / 412 0 / 423; 0 / 414
    { viewTransition: false } 0 / 408; 0 / 409 0 / 417; 0 / 414

    Both escape hatches remove it:

    • viewTransition: false places no router boundary, and the commit is still held.
    • A when that returns false commits urgently, with every router <ViewTransition> class set to "none". The skeleton streams and nothing animates.

    An app-placed <ViewTransition> around a layout's outlet still starts the idle transition under either escape hatch (2 to 3 calls, 618 to 665 ms). update="none" on that boundary removes it (0 calls, 407 to 410 ms). That boundary is the app's to configure.

    The docs (the view-transitions skill and guide) and a dev + production test per app that counts view transitions will come in a PR that closes this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions