Skip to content

perf(router-core): defer native AbortController.abort() until a signal is read - #8718

Draft
Sheraff wants to merge 1 commit into
mainfrom
perf/defer-native-abort
Draft

Sheraff wants to merge 1 commit into
mainfrom
perf/defer-native-abort

Conversation

@Sheraff

@Sheraff Sheraff commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

🎯 Changes

AI-authored (Claude Code), reviewed by a human before merge.

Every client navigation aborts at least one router controller (the previous transaction's, plus released loader flights). A reasonless native abort() eagerly creates its AbortError, and Chromium and Node format a stack for it: 1.6–5 µs per call in Chromium, 2.4–3.5 µs in Node, 0.5 µs in WebKit. Almost none of these signals are ever read.

RouterAbortController (in load-client.ts) defers the native abort until .signal is read:

get signal() { this._o = true; if (this._a) super.abort(this._a[0]); return super.signal }
abort(reason) { if (!this._a) { this._a = [reason]; this._w?.forEach((f) => f()) } if (this._o) super.abort(this._a[0]) }
  • Internal cancellation checks read _a instead of signal.aborted. waitFor takes the controller and rejects with it.
  • Internal waits registered while the signal is unread are parked in _w and settle before the native abort. No public listener can exist before the first read, so listener ordering is unchanged. Once the signal is read, internal waits use native listeners again.
  • Reading the signal after the router aborted it gives aborted === true with the engine's AbortError. The first reason wins, including falsy custom reasons. Listeners added before the abort fire synchronously, as before.
  • Native aborts per client-nav step drop from 1–2.1 to 0.

Perf: client-nav medians in µs/step, 5 alternating rounds in fresh processes, with an A/A control:

scenario Chromium A/A jsdom A/A
loaders 66.4 → 55.5 (−16.4%) +0.8%
async-pipeline 69.5 → 56.4 (−18.8%) +0.5%
nested-params 173.7 → 163.0 (−6.1%) −1.7% 225.4 → 218.8 (−2.9%) +0.2%

WebKit was neutral in earlier runs. Firefox was not measured.

Bundle size (gzip vs main). Raw size shrinks in every scenario (−7 to −79 B). The gzip growth is the class plus the parked-wait branch: their tokens are new, while the removed .signal.aborted reads compressed well.

scenario gzip scenario gzip
react-router.minimal +25 react-router.full +71
solid-router.minimal +56 solid-router.full +54
vue-router.minimal +65 vue-router.full +61
react-start.* +39..+72 solid-start.* +52..+87
vue-start.* +46..+52

Deferring only the abort, without parking internal waits, is +12 B on react-router.minimal but gets only about −6% in Chromium on loaders and async-pipeline. Always parking internal waits, even after the signal is read, would be +5 B, but it changes ordering in a way users can observe, so I rejected it. A test pins that ordering.

Semantics review: six adversarial reviews compared PR and main differentially, using public APIs only. The areas were self-abort, reasons, listener/microtask timing, lanes and shared flights, identity/environment, and memory. None found a behaviour difference. Remaining caveats:

  • An AbortError created by a deferred abort has a stack that points at the first .signal read, not at the abort() call.
  • Controllers now carry the own enumerable fields _a, _o and _w. They are visible in Object.keys, JSON.stringify and the devtools Explorer. Symbol keys would hide them, at +39 B gzip.
  • Freezing a router controller (Object.freeze(abortController)) now breaks it.
  • abortController.constructor !== AbortController. instanceof AbortController still holds.
  • AbortController must exist when router-core is imported. All supported runtimes provide it.

Tests: tests/deferred-abort-signal.test.ts pins:

  • late reads;
  • listeners added before the abort;
  • custom and falsy reasons;
  • fetch-like abort-work ordering;
  • preload waits settling after clearCache/invalidate, with the signal unread or read late;
  • adopted preload self-abort keeping its data;
  • a spy on AbortController.prototype.abort that observes the optimization. This is the only test that fails on main.

Mutants caught: missing flush, dropping parked waits on read, always parking, flushing without the reason, a scalar _a.

Passing:

  • router-core 3675 tests, react-router 1239, solid-router 1030 + 75, vue-router 1020;
  • test:types and test:eslint for all four;
  • e2e: react-router/basic-file-based (206), react-start/basic vite-ssr (139), react-start/selective-ssr (11).

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 43ed281

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 23 packages
Name Type
@tanstack/router-core Patch
@tanstack/react-router Patch
@tanstack/react-start-client Patch
@tanstack/react-start-rsc Patch
@tanstack/react-start-server Patch
@tanstack/router-generator Patch
@tanstack/router-plugin Patch
@tanstack/solid-router Patch
@tanstack/solid-start-client Patch
@tanstack/solid-start-server Patch
@tanstack/start-client-core Patch
@tanstack/start-plugin-core Patch
@tanstack/start-server-core Patch
@tanstack/start-storage-context Patch
@tanstack/vue-router Patch
@tanstack/vue-start-client Patch
@tanstack/vue-start-server Patch
@tanstack/react-start Patch
@tanstack/router-cli Patch
@tanstack/router-vite-plugin Patch
@tanstack/solid-start Patch
@tanstack/start-static-server-functions Patch
@tanstack/vue-start Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 43ed281

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 10m 1s View ↗

☁️ Nx Cloud last updated this comment at 2026-10-15 08:02:30 UTC

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

1 package(s) bumped directly, 22 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/router-core 1.171.37 → 1.171.38 Changeset
@tanstack/react-router 1.170.44 → 1.170.45 Dependent
@tanstack/react-start 1.169.2 → 1.169.3 Dependent
@tanstack/react-start-client 1.168.42 → 1.168.43 Dependent
@tanstack/react-start-rsc 0.1.63 → 0.1.64 Dependent
@tanstack/react-start-server 1.167.50 → 1.167.51 Dependent
@tanstack/router-cli 1.167.44 → 1.167.45 Dependent
@tanstack/router-generator 1.167.44 → 1.167.45 Dependent
@tanstack/router-plugin 1.168.46 → 1.168.47 Dependent
@tanstack/router-vite-plugin 1.167.46 → 1.167.47 Dependent
@tanstack/solid-router 1.170.41 → 1.170.42 Dependent
@tanstack/solid-start 1.169.2 → 1.169.3 Dependent
@tanstack/solid-start-client 1.168.40 → 1.168.41 Dependent
@tanstack/solid-start-server 1.167.48 → 1.167.49 Dependent
@tanstack/start-client-core 1.170.37 → 1.170.38 Dependent
@tanstack/start-plugin-core 1.171.53 → 1.171.54 Dependent
@tanstack/start-server-core 1.169.43 → 1.169.44 Dependent
@tanstack/start-static-server-functions 1.167.42 → 1.167.43 Dependent
@tanstack/start-storage-context 1.167.39 → 1.167.40 Dependent
@tanstack/vue-router 1.170.40 → 1.170.41 Dependent
@tanstack/vue-start 1.169.2 → 1.169.3 Dependent
@tanstack/vue-start-client 1.167.43 → 1.167.44 Dependent
@tanstack/vue-start-server 1.167.48 → 1.167.49 Dependent

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

  • Commit: b5a1e2b2c5da
  • Measured at: 2026-10-15T07:33:23.399Z
  • Baseline source: history:140efa434785
  • Dashboard: bundle-size history

The following scenarios have bundle-size changes compared with the baseline:

Scenario Current (gzip) Initial (gzip) Raw Brotli Trend
react-router.minimal 84.1 KiB
+25 B
83.9 KiB
+27 B
261.4 KiB
-28 B
73.4 KiB
+73 B
▁▁▁▁▁▁▁▁▁▁▁█
react-router.full 87.6 KiB
+71 B
87.5 KiB
+71 B
273.3 KiB
-7 B
76.4 KiB
+43 B
▁▁▁▁▁▁▁▁▁▁▁█
solid-router.minimal 33.4 KiB
+56 B
33.3 KiB
+58 B
95.2 KiB
-20 B
30.1 KiB
-22 B
▁▁▁▁▁▁▁▁▁▁▁█
solid-router.full 38.3 KiB
+54 B
38.2 KiB
+55 B
110.0 KiB
-20 B
34.5 KiB
-43 B
▁▁▁▁▁▁▁▁▁▁▁█
vue-router.minimal 49.5 KiB
+65 B
49.4 KiB
+66 B
136.4 KiB
-20 B
44.9 KiB
+98 B
▁▁▁▁▁▁▁▁▁▁▁█
vue-router.full 55.1 KiB
+61 B
54.9 KiB
+62 B
154.6 KiB
-20 B
49.7 KiB
+61 B
▁▁▁▁▁▁▁▁▁▁▁█
react-start.minimal 96.8 KiB
+42 B
96.7 KiB
+45 B
302.3 KiB
-75 B
84.1 KiB
-9 B
▁▁▁▁▁▁▁▁▁▁▁█
react-start.query-integration 104.3 KiB
+59 B
104.1 KiB
+58 B
328.9 KiB
-71 B
90.5 KiB
+2 B
▁▁▁▁▁▁▁▁▁▁▁█
react-start.deferred-hydration 97.5 KiB
+72 B
96.7 KiB
+70 B
303.6 KiB
-75 B
84.8 KiB
+105 B
▁▁▁▁▁▁▁▁▁▁▁█
react-start.full 99.9 KiB
+72 B
99.8 KiB
+71 B
311.5 KiB
-79 B
86.7 KiB
+14 B
▄▁▁▁▁▁▁▁▁▁▁█
react-start.rsbuild.minimal 100.4 KiB
+43 B
100.2 KiB
+43 B
313.2 KiB
-73 B
86.7 KiB
+89 B
▁▁▁▁▁▁▁▁▁▁▁█
react-start.rsbuild.minimal-iife 100.8 KiB
+44 B
100.6 KiB
+44 B
314.1 KiB
-73 B
87.1 KiB
+38 B
▂▂▂▂▁▁▁▁▁▁▁█
react-start.rsbuild.full 103.6 KiB
+39 B
103.4 KiB
+39 B
322.8 KiB
-73 B
89.4 KiB
+36 B
▅▁▁▁▂▂▂▂▂▂▂█
solid-start.minimal 46.2 KiB
+56 B
46.1 KiB
+54 B
135.0 KiB
-73 B
41.2 KiB
+74 B
▁▁▁▁▁▁▁▁▁▁▁█
solid-start.deferred-hydration 49.3 KiB
+52 B
46.1 KiB
+52 B
142.3 KiB
-73 B
44.0 KiB
+107 B
▁▁▁▁▁▁▁▁▁▁▁█
solid-start.full 51.2 KiB
+87 B
51.1 KiB
+84 B
149.9 KiB
-73 B
45.5 KiB
+143 B
▄▁▁▁▁▁▁▁▁▁▁█
vue-start.minimal 65.4 KiB
+46 B
65.3 KiB
+45 B
185.6 KiB
-73 B
58.4 KiB
+97 B
▁▁▁▁▁▁▁▁▁▁▁█
vue-start.full 69.1 KiB
+52 B
69.0 KiB
+53 B
197.5 KiB
-73 B
61.7 KiB
+107 B
▅▁▁▁▁▁▁▁▁▁▁█

Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better.

@pkg-pr-new

pkg-pr-new Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@8718

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@8718

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@8718

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@8718

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@8718

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@8718

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@8718

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@8718

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@8718

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@8718

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@8718

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@8718

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@8718

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@8718

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@8718

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@8718

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@8718

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@8718

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@8718

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@8718

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@8718

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@8718

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@8718

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@8718

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@8718

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@8718

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@8718

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@8718

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@8718

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@8718

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@8718

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@8718

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@8718

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@8718

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@8718

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@8718

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@8718

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@8718

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@8718

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@8718

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@8718

commit: 43ed281

@codspeed

codspeed Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 17.51%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 21 improved benchmarks
✅ 173 untouched benchmarks
⏩ 3 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ Memory mem client loader-data-retention (solid) 98 KB 65.5 KB +49.52%
⚡ Memory mem client interrupted-navigations (solid) 79.8 KB 55.1 KB +44.78%
⚡ Memory mem client loader-data-retention (react) 100.3 KB 73.7 KB +36.15%
⚡ Memory mem client interrupted-navigations (react) 83.2 KB 62.7 KB +32.73%
⚡ Memory mem client unique-location-churn (react) 71.8 KB 54.2 KB +32.32%
⚡ Memory mem client preload-churn (vue) 75 KB 59.2 KB +26.71%
⚡ Memory mem client unique-location-churn (vue) 96.5 KB 76.9 KB +25.51%
⚡ Memory mem client preload-churn (solid) 77 KB 62.3 KB +23.49%
⚡ Memory mem client interrupted-navigations (vue) 98 KB 82.1 KB +19.29%
⚡ Memory mem client preload-churn (react) 71.9 KB 61.6 KB +16.64%
⚡ Memory mem client navigation-churn (react) 63.4 KB 56.2 KB +12.75%
⚡ Memory mem client mount-unmount (solid) 81.7 KB 72.5 KB +12.68%
⚡ Memory mem client navigation-churn (vue) 115.9 KB 103.5 KB +11.89%
⚡ Memory mem client navigation-churn (solid) 91.6 KB 82 KB +11.72%
⚡ Simulation client-async-pipeline navigation loop (react) 62.5 ms 59.7 ms +4.84%
⚡ Memory mem client mount-unmount (vue) 147.7 KB 141.2 KB +4.59%
⚡ Simulation client-async-pipeline navigation loop (vue) 67.4 ms 64.5 ms +4.48%
⚡ Simulation client-control-flow navigation loop (vue) 64.1 ms 61.4 ms +4.32%
⚡ Simulation client-loaders navigation loop (react) 90.8 ms 87.6 ms +3.69%
⚡ Simulation client-control-flow navigation loop (react) 89.5 ms 86.7 ms +3.19%
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/defer-native-abort (43ed281) with main (6777b8b)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

…l is read

A reasonless native abort() eagerly creates its AbortError, and Chromium and
Node format a stack for it (1.6-5 us per call). Every client navigation aborts
at least one router controller, and almost none of their signals are ever read.

RouterAbortController records the abort internally and performs the native
abort only once the signal has been read: the signal getter flushes a deferred
abort, so readers still see an aborted signal with the engine's AbortError and
the first reason wins. Internal cancellation checks use the internal record.
Internal waits registered while the signal is unread settle without native
listeners, before any public listener could have been registered, so listener
ordering is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Sheraff
Sheraff force-pushed the perf/defer-native-abort branch from 5e413ff to 43ed281 Compare October 15, 2026 07:30

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant