
From application code, a navigation looks almost too simple:
await router.navigate({ to: '/account' })That simplicity gets deceptive as soon as a few things overlap.
This is what the scenario looks like on a timeline. Don't worry about the labels yet, we'll unpack them as we go.
Why can /account keep loading after the user moves on, but no longer update the page? Why does the /settings error never appear? And why isn't publishing /login the end of the navigation?
A navigation looks like one asynchronous operation, but the router must decide four things independently: which loading work should continue, which navigation may publish, what the route attempt decided, and whether the framework rendered it.
That distinction came out of rewriting TanStack Router's loading system. We had dozens of bugs across preloading, redirects, caching, pending UI, SSR, and more. They looked unrelated, but many had the same root cause: the router compressed too many facts into too few variables. Cancellation, supersession, publication, and rendering were allowed to stand in for each other.1
Application code still gets one navigate() call. Inside the router, separate lifetimes keep track of those four answers.2
Before we return to all that overlap, it helps to focus on one ordinary navigation. There is no second click, no error, and no redirect here. The route simply loads slowly enough to show pending UI, then succeeds.
Each row follows a different part of the navigation, and the arrows between them are handoffs. Loaders can run while a pending timer races them. The framework can render pending UI while the private route branch keeps loading. We'll decode the internal labels just below.
The lane in the middle is a private, unpublished draft of the matched route branch. Following it across the diagram:
Note
The diagram's release flight label marks where the lane releases its temporary claim. That does not necessarily end ownership of settled data. The loader-flight section below explains why.
Nothing goes wrong in this version. The transaction is never replaced, the loaders do not error or redirect, and both publications render successfully. Now change one assumption at a time:
| What if... | The answer belongs to |
|---|---|
| another consumer needs a loader that is already running? | A loader flight and its leases |
| the user starts another navigation before this one finishes? | The current transaction |
| parallel loaders produce different kinds of outcomes? | A private lane |
| another publication takes over before the framework renders this one? | A framework render receipt |
Usually, all four answers arrive within a few milliseconds of each other, preserving the illusion of one asynchronous task, but it is also plenty of time for another event to change what should happen next.
The opening scenario puts all four complications back together. Here is the whole thing at once, and then one section per complication.
Unlike the other diagrams, this one reads from top to bottom. Each column follows one owner: the current transaction, a private lane, a loader flight, or the framework render. The horizontal arrows pass work or results between them. This is only one possible interleaving; independent events, such as caching /account and publishing /login, could happen in either order.
In the opening scenario, /account keeps loading after the user clicks away. Both the hover preload and the navigation need /account's data, but the loader should still run only once.
The preload and navigation still do their own matching, build their own context, and run their own beforeLoad. They share only the loader invocation and its outcome.
To decide when that shared invocation can be aborted, the router wraps it in a flight: a promise, an abort controller, and a lease count. Every consumer that needs the flight takes a lease.4
For /account, clicking /settings ends the navigation's claim. The preload still holds its own lease, so the count never reaches zero. The flight continues, settles, and its result enters the cache.
Losing permission to publish /account does not prove that its loader is useless. The transaction enforces whether /account may publish, and the flight's leases track whether the loader is still needed.
Note
Preload and navigation lanes, published routes, and cache entries can all hold leases. A lease can also outlive the promise it covers: the lease represents ownership of the resource, not a promise lifecycle.
Keeping a loader alive is separate from deciding whether its navigation may reach the page. In the simple example, one transaction stays current all the way through publication. In the complex scenario, /settings starts while the /account lane is still pending.
The current transaction is a single slot that holds which navigation is allowed to publish. When /settings takes the slot, /account loses that right. It does not matter whether some of /account's work is still useful; the lane can no longer update the page.
Discarding the navigation also releases its leases. Any pending flight with no other consumers may now abort. Our /account flight has the preload consumer, though, so it carries on.
Note
The implementation check is deliberately small. At an asynchronous boundary, a lane that no longer holds the slot cleans up and returns instead of publishing:
if (router._tx !== tx) {
finishPending(tx)
discardLane(result)
return
}In the successful navigation, every loader contributes to one successful result. The diagram below shows three outcomes: the root route succeeds, the parent layout loader rejects, and the child index loader throws redirect('/login').
Each settlement tells us what happened to one loader, but not yet what the whole route attempt should do. The outcomes go back to the private lane, which reduces them into one result.5
The error does not become the lane's result just because it settles first. Before choosing an ordinary failure, the lane waits for already-started loaders that could still redirect. It eventually chooses one result for the whole branch:
It is tempting to say that the redirect "beats" the error, but that is not quite what happens. A redirect is control flow and not really a piece of UI. It discards the /settings lane and starts a new navigation to /login. Since that lane never publishes its final matches, the error never reaches the page.
Note
The lane's TypeScript type changes with each phase: matched, contextualized, reduced, then projected. These labels help ensure that the lane goes through each stage in the correct order without adding runtime states.6
This section depends on the UI framework (React / Solid / Vue). We will use React for this explanation.
Both scenarios represent the end of a successful lane as two separate events: publish and acknowledge. Publishing means handing a route branch to the framework. It is a request to render, not proof that the framework committed that branch.7
React may still be busy with the previous tree. The new one can suspend on promises of its own (useSuspenseQuery, use(promise), lazy(() => import(...)), etc.), leaving the committed UI on screen. If another navigation publishes in the meantime, the earlier publication may never commit at all.
The diagram below shows that generic race.
To tell these cases apart, the router keeps a single receipt slot. A new publication acknowledges (ack) the previous receipt with false before installing its own. If React commits the new publication, the adapter acknowledges that receipt with true.8
ack:false does not mean the navigation failed. It means that exact publication was replaced before it committed. Either answer settles the receipt, but only ack:true proves that the framework rendered the publication.
Note
The receipt belongs to one exact publication. Either answer releases the internal render wait, but what happens next depends on the value:
| ack:true | ack:false | |
|---|---|---|
| The navigation resolves | ✅ | 🤷 |
| onResolved, if the transaction is still current | ✅ | ✅ |
| onRendered, if the transaction is still current | ✅ | ❌ |
| A pending fallback starts its pendingMinMs9 | ✅ | ❌ |
A superseded public navigate() can remain chained to the navigation that replaced it, so ack:false does not guarantee that the navigation resolves.
Rendered here means that React committed the tree and ran its layout effects. It does not necessarily mean that the browser painted it.
In the opening scenario, the user lands on /login, but four separate decisions produce that result. The /account loader continues because its preload still holds a lease. That same /account lane can no longer publish, because /settings took the transaction. The /settings lane sees an error, but its redirect starts a new /login navigation instead of producing UI. The framework then reports when /login actually commits.
navigate() can remain one promise because the router keeps these lifetimes separate. A lease says whether loader work is still needed. The current transaction says which navigation may publish. A private lane decides what one route attempt did. A receipt reports whether the framework rendered a publication.
await router.navigate({ to: '/account' })Note
We have seen how the 4 main owners of a navigation work: transaction, lane, flight, render receipt. But the system has more complexity if you want to investigate further:
Those details are not needed to follow the client navigation above.10
Examples include shared loader work being canceled when a navigation was replaced (#3928, #7759), route attempts sharing state that only their loader result could safely share (#3179, #4572, #7602), redirects reaching route UI (#7120, #7367, #7753), and published state being reported as rendered before the framework committed it (render-owner contract). ↩
The rewrite's internal architecture guide lists the independent publishing, resource, presentation, preload, hydration, and server authorities. The four tracks in this article cover the common client-side story; they are not the complete inventory. ↩
The diagram compresses planning and execution into one navigation-authority track. In the implementation, a short-lived _preflight owner protects events and route matching before the foreground transaction is installed. ↩
A LoaderFlight contains one normalized outcome promise, its own abort controller, and a lease count. The registry and release rules keep discoverability separate from ownership. ↩
Before the rewrite, the loader path already waited for started tasks and preferred redirect control flow. Regression coverage for that existing behavior includes a shared-flight variant. In the new pipeline, settleTasks records outcomes and reduceLane selects one semantic lane or redirect. ↩
The phase-branded lane types record how far along the pipeline a lane is, in the type system only. The brand is a compile-time marker that does not exist at runtime, so a function that requires a reduced lane simply will not accept one that has only been matched. ↩
The final client publication callback commits the matches and emits onLoad and onBeforeRouteMount before awaiting the framework receipt; commitMatches runs the route lifecycle callbacks. After that receipt settles, the router emits onResolved, and emits onRendered only for a current positive acknowledgement. ↩
Before the rewrite, React's adapter used global loading and transition flags, while startTransition itself returned no receipt. It now acknowledges the exact offered match-array reference through a transition owner and a Matches layout effect. Solid awaits Solid.startTransition, while Vue awaits its render tick. ↩
The PendingSession owns one reveal/minimum-visible deadline and its acknowledgement. offerPending starts the minimum only after a positive render acknowledgement. ↩
The architecture guide's authority table and code map cover client planning, presentation, cache, hydration, refresh, server requests, and accepted streams. Its background-reload model keeps a candidate private and guards publication by transaction and committed-base identity. Normal component chunks participate in loader readiness, scroll restoration subscribes to onRendered, and final client publication runs inside the router's view-transition boundary. ↩