diff --git a/.github/workflows/memory-benchmarks.yml b/.github/workflows/memory-benchmarks.yml
new file mode 100644
index 0000000000..395292d7b3
--- /dev/null
+++ b/.github/workflows/memory-benchmarks.yml
@@ -0,0 +1,68 @@
+name: Memory Benchmarks
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/**'
+ - 'benchmarks/**'
+ - '.github/workflows/memory-benchmarks.yml'
+ pull_request:
+ paths:
+ - 'packages/**'
+ - 'benchmarks/**'
+ - '.github/workflows/memory-benchmarks.yml'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+
+env:
+ NX_NO_CLOUD: true
+ MEMORY_BENCH_ITERATIONS: 2000
+ MEMORY_BENCH_WARMUP_ITERATIONS: 200
+ MEMORY_BENCH_BATCH_SIZE: 100
+
+jobs:
+ benchmarks:
+ name: Run Memory Benchmarks
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Setup Tools
+ uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main
+
+ - name: Install Chromium
+ run: pnpm exec playwright install chromium
+
+ - name: Run React Memory Benchmarks
+ run: CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react --outputStyle=stream --skipRemoteCache --parallel=2
+
+ - name: Build Benchmark Report
+ run: CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:report --outputStyle=stream --skipRemoteCache
+
+ - name: Publish Benchmark Dashboard
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository_owner == 'TanStack'
+ uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
+ with:
+ tool: customSmallerIsBetter
+ name: Memory Usage (retained heap)
+ output-file-path: benchmarks/memory/results/benchmark-action.json
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ auto-push: true
+ gh-pages-branch: gh-pages
+ benchmark-data-dir-path: benchmarks/memory
+ max-items-in-chart: 200
+ summary-always: true
+ comment-on-alert: false
+ fail-on-alert: false
diff --git a/benchmarks/memory/.gitignore b/benchmarks/memory/.gitignore
new file mode 100644
index 0000000000..d0b06dd57a
--- /dev/null
+++ b/benchmarks/memory/.gitignore
@@ -0,0 +1 @@
+scenarios/**/dist
diff --git a/benchmarks/memory/README.md b/benchmarks/memory/README.md
new file mode 100644
index 0000000000..517c03f9bf
--- /dev/null
+++ b/benchmarks/memory/README.md
@@ -0,0 +1,62 @@
+# Memory Benchmarks
+
+Memory benchmarks are organized as Nx leaf targets. Each leaf target runs one scenario, prints the result to stdout, and writes one isolated result artifact under `results/scenarios/`.
+
+## Run
+
+Run all memory benchmarks:
+
+```bash
+CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory --outputStyle=stream --skipRemoteCache --parallel=2
+```
+
+Run React scenarios:
+
+```bash
+CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react --outputStyle=stream --skipRemoteCache --parallel=2
+```
+
+Run one scenario:
+
+```bash
+CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react:client:repeated-navigation --outputStyle=stream --skipRemoteCache
+CI=1 NX_DAEMON=false pnpm nx run @benchmarks/memory:test:memory:react:ssr:repeated-requests --outputStyle=stream --skipRemoteCache
+```
+
+Tune local run sizes with environment variables:
+
+```bash
+MEMORY_BENCH_ITERATIONS=10000 MEMORY_BENCH_WARMUP_ITERATIONS=1000 pnpm nx run @benchmarks/memory:test:memory:react:ssr:repeated-requests
+```
+
+The SSR benchmark also waits for pending macrotasks before retained-heap GC
+sampling. Tune this with `MEMORY_BENCH_SETTLE_TICKS` when investigating runtime
+scheduler behavior.
+
+## Report
+
+After leaf targets have run, merge isolated scenario artifacts into benchmark-action compatible outputs:
+
+```bash
+pnpm nx run @benchmarks/memory:report
+```
+
+This writes:
+
+- `benchmarks/memory/results/current.json`
+- `benchmarks/memory/results/benchmark-action.json`
+
+## CI
+
+`.github/workflows/memory-benchmarks.yml` runs the React memory benchmark group with Nx parallelism, builds the report, and publishes the main-branch history to GitHub Pages via `benchmark-action/github-action-benchmark`.
+
+The workflow uses a larger run size than quick local smoke tests:
+
+```bash
+MEMORY_BENCH_ITERATIONS=2000 MEMORY_BENCH_WARMUP_ITERATIONS=200 MEMORY_BENCH_BATCH_SIZE=100
+```
+
+## Initial Scenarios
+
+- `react.client.repeated-navigation`: React Router in Chromium via Playwright.
+- `react.ssr.repeated-requests`: React Start SSR in Node.
diff --git a/benchmarks/memory/package.json b/benchmarks/memory/package.json
new file mode 100644
index 0000000000..2012be1324
--- /dev/null
+++ b/benchmarks/memory/package.json
@@ -0,0 +1,124 @@
+{
+ "name": "@benchmarks/memory",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build:react:client:repeated-navigation": "NODE_ENV=production vite build --config ./scenarios/react/client/repeated-navigation/vite.config.ts",
+ "build:react:ssr:repeated-requests": "NODE_ENV=production vite build --config ./scenarios/react/ssr/repeated-requests/vite.config.ts",
+ "test:memory": "node ./tools/summary.ts",
+ "test:memory:react": "node ./tools/summary.ts --scope react",
+ "test:memory:react:client": "node ./tools/summary.ts --scope react.client",
+ "test:memory:react:ssr": "node ./tools/summary.ts --scope react.ssr",
+ "test:memory:react:client:repeated-navigation": "NODE_ENV=production playwright test --config ./scenarios/react/client/repeated-navigation/playwright.config.ts",
+ "test:memory:react:ssr:repeated-requests": "NODE_ENV=production node --expose-gc ./scenarios/react/ssr/repeated-requests/bench.ts",
+ "report": "node ./tools/report.ts",
+ "test:types": "pnpm run test:types:tools && pnpm run test:types:react:client:repeated-navigation && pnpm run test:types:react:ssr:repeated-requests",
+ "test:types:tools": "tsc -p ./tools/tsconfig.json --noEmit",
+ "test:types:react:client:repeated-navigation": "tsc -p ./scenarios/react/client/repeated-navigation/tsconfig.json --noEmit",
+ "test:types:react:ssr:repeated-requests": "tsc -p ./scenarios/react/ssr/repeated-requests/tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:*",
+ "@tanstack/react-start": "workspace:*",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.57.0",
+ "@types/node": "25.0.9",
+ "@types/react": "^19.2.8",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "typescript": "^6.0.2",
+ "vite": "^8.0.14"
+ },
+ "nx": {
+ "targets": {
+ "build:react:client:repeated-navigation": {
+ "cache": false,
+ "dependsOn": [
+ {
+ "projects": [
+ "@tanstack/react-router"
+ ],
+ "target": "build"
+ }
+ ],
+ "outputs": [
+ "{projectRoot}/scenarios/react/client/repeated-navigation/dist"
+ ]
+ },
+ "build:react:ssr:repeated-requests": {
+ "cache": false,
+ "dependsOn": [
+ {
+ "projects": [
+ "@tanstack/react-start"
+ ],
+ "target": "build"
+ }
+ ],
+ "outputs": [
+ "{projectRoot}/scenarios/react/ssr/repeated-requests/dist"
+ ]
+ },
+ "test:memory": {
+ "cache": false,
+ "dependsOn": [
+ "test:memory:react:client:repeated-navigation",
+ "test:memory:react:ssr:repeated-requests"
+ ]
+ },
+ "test:memory:react": {
+ "cache": false,
+ "dependsOn": [
+ "test:memory:react:client:repeated-navigation",
+ "test:memory:react:ssr:repeated-requests"
+ ]
+ },
+ "test:memory:react:client": {
+ "cache": false,
+ "dependsOn": [
+ "test:memory:react:client:repeated-navigation"
+ ]
+ },
+ "test:memory:react:ssr": {
+ "cache": false,
+ "dependsOn": [
+ "test:memory:react:ssr:repeated-requests"
+ ]
+ },
+ "test:memory:react:client:repeated-navigation": {
+ "cache": false,
+ "dependsOn": [
+ "build:react:client:repeated-navigation"
+ ],
+ "outputs": [
+ "{projectRoot}/results/scenarios/react.client.repeated-navigation.json"
+ ]
+ },
+ "test:memory:react:ssr:repeated-requests": {
+ "cache": false,
+ "dependsOn": [
+ "build:react:ssr:repeated-requests"
+ ],
+ "outputs": [
+ "{projectRoot}/results/scenarios/react.ssr.repeated-requests.json"
+ ]
+ },
+ "report": {
+ "cache": false,
+ "outputs": [
+ "{projectRoot}/results/current.json",
+ "{projectRoot}/results/benchmark-action.json"
+ ]
+ },
+ "test:types": {
+ "cache": false,
+ "dependsOn": [
+ "^build"
+ ]
+ }
+ }
+ }
+}
diff --git a/benchmarks/memory/results/.gitignore b/benchmarks/memory/results/.gitignore
new file mode 100644
index 0000000000..30af9d23c2
--- /dev/null
+++ b/benchmarks/memory/results/.gitignore
@@ -0,0 +1,5 @@
+*
+!.gitignore
+!scenarios/
+scenarios/*
+!scenarios/.gitkeep
diff --git a/benchmarks/memory/results/scenarios/.gitkeep b/benchmarks/memory/results/scenarios/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/index.html b/benchmarks/memory/scenarios/react/client/repeated-navigation/index.html
new file mode 100644
index 0000000000..b21cbcd6e5
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ React Router Memory Benchmark
+
+
+
+
+
+
diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/playwright.config.ts b/benchmarks/memory/scenarios/react/client/repeated-navigation/playwright.config.ts
new file mode 100644
index 0000000000..e8cf512c2a
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/playwright.config.ts
@@ -0,0 +1,35 @@
+import { defineConfig, devices } from '@playwright/test'
+
+const port = Number.parseInt(
+ process.env.MEMORY_BENCH_REACT_CLIENT_PORT ?? '42101',
+ 10,
+)
+const baseURL = `http://127.0.0.1:${port}`
+
+export default defineConfig({
+ testDir: './tests',
+ timeout: 120_000,
+ workers: 1,
+ reporter: [['line']],
+ use: {
+ baseURL,
+ ...devices['Desktop Chrome'],
+ },
+ webServer: {
+ command: `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`,
+ url: baseURL,
+ reuseExistingServer: false,
+ stdout: 'pipe',
+ env: {
+ NODE_ENV: 'production',
+ },
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ },
+ },
+ ],
+})
diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/src/main.tsx b/benchmarks/memory/scenarios/react/client/repeated-navigation/src/main.tsx
new file mode 100644
index 0000000000..1f0461d719
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/src/main.tsx
@@ -0,0 +1,235 @@
+import * as React from 'react'
+import ReactDOM from 'react-dom/client'
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ useParams,
+ useSearch,
+} from '@tanstack/react-router'
+
+declare global {
+ interface Window {
+ __memoryBenchmark: {
+ runBatch: (iterations: number) => Promise
+ }
+ }
+}
+
+type SearchState = {
+ filter?: string
+ page?: number
+ q?: string
+}
+
+const probes = Array.from({ length: 20 }, (_, index) => index)
+
+function validateSearch(search: Record): SearchState {
+ const page = Number(search.page)
+
+ return {
+ filter: typeof search.filter === 'string' ? search.filter : undefined,
+ page: Number.isFinite(page) ? page : undefined,
+ q: typeof search.q === 'string' ? search.q : undefined,
+ }
+}
+
+function ParamsProbe({ index }: { index: number }) {
+ const params = useParams({
+ strict: false,
+ })
+
+ return (
+
+ {index}:{String(params.itemId ?? params.a ?? '')}
+
+ )
+}
+
+function SearchProbe({ index }: { index: number }) {
+ const search = useSearch({
+ strict: false,
+ })
+
+ return (
+
+ {index}:{String(search.q ?? search.filter ?? search.page ?? '')}
+
+ )
+}
+
+function LinkProbe({ index }: { index: number }) {
+ return (
+
+ item {index}
+
+ )
+}
+
+function Workload({ label }: { label: string }) {
+ return (
+
+ {label}
+ {probes.map((probe) => (
+
+ ))}
+ {probes.map((probe) => (
+
+ ))}
+
+
+ )
+}
+
+const rootRoute = createRootRoute({
+ component: RootComponent,
+ validateSearch,
+})
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => ,
+})
+
+const itemRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/items/$itemId',
+ validateSearch,
+ component: () => ,
+})
+
+const searchRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/search',
+ validateSearch,
+ component: () => ,
+})
+
+const nestedRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/nested/$a/$b/$c',
+ validateSearch,
+ component: () => ,
+})
+
+const routeTree = rootRoute.addChildren([
+ indexRoute,
+ itemRoute,
+ searchRoute,
+ nestedRoute,
+])
+
+const router = createRouter({
+ routeTree,
+ defaultPreload: false,
+ scrollRestoration: false,
+})
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+function RootComponent() {
+ return
+}
+
+function waitForRendered() {
+ return new Promise((resolve) => {
+ const unsubscribe = router.subscribe('onRendered', () => {
+ unsubscribe()
+ resolve()
+ })
+ })
+}
+
+async function navigateAndWait(options: any) {
+ const rendered = waitForRendered()
+ await router.navigate(options)
+ await rendered
+}
+
+let actionIndex = 0
+
+window.__memoryBenchmark = {
+ async runBatch(iterations) {
+ for (let index = 0; index < iterations; index++) {
+ const currentIndex = actionIndex
+ actionIndex += 1
+
+ const value = String(currentIndex % 10_000)
+ const step = currentIndex % 4
+
+ if (step === 0) {
+ await navigateAndWait({
+ to: '/items/$itemId',
+ params: { itemId: value },
+ search: {
+ filter: 'all',
+ page: currentIndex % 25,
+ q: `item-${value}`,
+ },
+ replace: true,
+ })
+ } else if (step === 1) {
+ await navigateAndWait({
+ to: '/search',
+ search: {
+ filter: 'active',
+ page: currentIndex % 50,
+ q: `search-${value}`,
+ },
+ replace: true,
+ })
+ } else if (step === 2) {
+ await navigateAndWait({
+ to: '/nested/$a/$b/$c',
+ params: { a: `a-${value}`, b: `b-${value}`, c: `c-${value}` },
+ search: {
+ filter: 'nested',
+ page: currentIndex % 75,
+ q: `nested-${value}`,
+ },
+ replace: true,
+ })
+ } else {
+ await navigateAndWait({
+ to: '/',
+ search: {
+ filter: 'home',
+ page: currentIndex % 100,
+ q: `home-${value}`,
+ },
+ replace: true,
+ })
+ }
+ }
+
+ await navigateAndWait({
+ to: '/',
+ search: { filter: 'stable', page: 0, q: 'stable' },
+ replace: true,
+ })
+ },
+}
+
+const rootElement = document.getElementById('app')
+if (!rootElement) {
+ throw new Error('Root element `#app` not found')
+}
+
+ReactDOM.createRoot(rootElement).render()
diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/tests/memory.spec.ts b/benchmarks/memory/scenarios/react/client/repeated-navigation/tests/memory.spec.ts
new file mode 100644
index 0000000000..524eb94eb3
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/tests/memory.spec.ts
@@ -0,0 +1,136 @@
+import { test } from '@playwright/test'
+import type { CDPSession } from '@playwright/test'
+import {
+ computeSlopeBytesPerOperation,
+ createScenarioResult,
+ printScenarioResult,
+ readPositiveIntegerEnv,
+ writeScenarioResult,
+} from '../../../../../tools/result-utils.ts'
+import type { MemorySample } from '../../../../../tools/result-utils.ts'
+
+const scenario = {
+ id: 'react.client.repeated-navigation',
+ label: 'React client repeated navigation',
+ framework: 'react',
+ runtime: 'client',
+ scenario: 'repeated-navigation',
+}
+
+interface BrowserMemory {
+ heapUsedBytes: number
+ heapTotalBytes: number
+ documents: number
+ domNodes: number
+ jsEventListeners: number
+}
+
+async function forceBrowserGc(client: CDPSession) {
+ await client.send('HeapProfiler.collectGarbage')
+}
+
+async function readBrowserMemory(client: CDPSession): Promise {
+ const heap = await client.send('Runtime.getHeapUsage')
+ const domCounters = await client.send('Memory.getDOMCounters')
+
+ return {
+ heapUsedBytes: heap.usedSize,
+ heapTotalBytes: heap.totalSize,
+ documents: domCounters.documents,
+ domNodes: domCounters.nodes,
+ jsEventListeners: domCounters.jsEventListeners,
+ }
+}
+
+test('react client repeated navigation memory', async ({ page }) => {
+ const startedAt = new Date()
+ const iterations = readPositiveIntegerEnv('MEMORY_BENCH_ITERATIONS', 1000)
+ const warmupIterations = readPositiveIntegerEnv(
+ 'MEMORY_BENCH_WARMUP_ITERATIONS',
+ 100,
+ )
+ const batchSize = readPositiveIntegerEnv('MEMORY_BENCH_BATCH_SIZE', 50)
+ const client = await page.context().newCDPSession(page)
+
+ await client.send('HeapProfiler.enable')
+ await client.send('Runtime.enable')
+
+ await page.goto('/')
+ await page.waitForFunction(() => {
+ return typeof (window as any).__memoryBenchmark?.runBatch === 'function'
+ })
+
+ await page.evaluate(
+ (count) => (window as any).__memoryBenchmark.runBatch(count),
+ warmupIterations,
+ )
+ await forceBrowserGc(client)
+
+ const baseline = await readBrowserMemory(client)
+ const samples: Array = []
+ let peakHeapUsedBytes = baseline.heapUsedBytes
+
+ for (let completed = 0; completed < iterations; completed += batchSize) {
+ const nextBatchSize = Math.min(batchSize, iterations - completed)
+ await page.evaluate(
+ (count) => (window as any).__memoryBenchmark.runBatch(count),
+ nextBatchSize,
+ )
+
+ const beforeGc = await readBrowserMemory(client)
+ peakHeapUsedBytes = Math.max(peakHeapUsedBytes, beforeGc.heapUsedBytes)
+
+ await forceBrowserGc(client)
+ const afterGc = await readBrowserMemory(client)
+ const iteration = completed + nextBatchSize
+ samples.push({
+ iteration,
+ heapUsedBytes: afterGc.heapUsedBytes,
+ heapTotalBytes: afterGc.heapTotalBytes,
+ retainedHeapDeltaBytes: afterGc.heapUsedBytes - baseline.heapUsedBytes,
+ documents: afterGc.documents,
+ domNodes: afterGc.domNodes,
+ domNodeDelta: afterGc.domNodes - baseline.domNodes,
+ jsEventListeners: afterGc.jsEventListeners,
+ jsEventListenerDelta:
+ afterGc.jsEventListeners - baseline.jsEventListeners,
+ peakHeapUsedBytes,
+ })
+ }
+
+ const finalSample = samples[samples.length - 1]!
+ const retainedHeapDeltaBytes =
+ finalSample.heapUsedBytes - baseline.heapUsedBytes
+ const retainedHeapBytesPerOperation = retainedHeapDeltaBytes / iterations
+ const retainedHeapSlopeBytesPerOperation = computeSlopeBytesPerOperation(
+ samples,
+ (sample) => sample.retainedHeapDeltaBytes,
+ )
+ const result = createScenarioResult({
+ ...scenario,
+ startedAt,
+ config: {
+ iterations,
+ warmupIterations,
+ batchSize,
+ },
+ metrics: {
+ baselineHeapUsedBytes: baseline.heapUsedBytes,
+ finalHeapUsedBytes: finalSample.heapUsedBytes,
+ retainedHeapDeltaBytes,
+ retainedHeapBytesPerOperation,
+ retainedHeapSlopeBytesPerOperation,
+ peakHeapUsedBytes,
+ baselineDomNodes: baseline.domNodes,
+ finalDomNodes: finalSample.domNodes,
+ domNodeDelta: finalSample.domNodeDelta,
+ baselineJsEventListeners: baseline.jsEventListeners,
+ finalJsEventListeners: finalSample.jsEventListeners,
+ jsEventListenerDelta: finalSample.jsEventListenerDelta,
+ },
+ samples,
+ })
+
+ writeScenarioResult(result)
+ printScenarioResult(result)
+})
diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/tsconfig.json b/benchmarks/memory/scenarios/react/client/repeated-navigation/tsconfig.json
new file mode 100644
index 0000000000..e2bcedfe24
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "tests/**/*.ts",
+ "playwright.config.ts",
+ "vite.config.ts"
+ ]
+}
diff --git a/benchmarks/memory/scenarios/react/client/repeated-navigation/vite.config.ts b/benchmarks/memory/scenarios/react/client/repeated-navigation/vite.config.ts
new file mode 100644
index 0000000000..1ba2e24830
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/client/repeated-navigation/vite.config.ts
@@ -0,0 +1,18 @@
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+const rootDir = fileURLToPath(new URL('.', import.meta.url))
+
+export default defineConfig({
+ root: rootDir,
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ },
+ plugins: [react()],
+ build: {
+ outDir: './dist',
+ emptyOutDir: true,
+ minify: false,
+ },
+})
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts b/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts
new file mode 100644
index 0000000000..514b1161fe
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/bench.ts
@@ -0,0 +1,192 @@
+import {
+ computeSlopeBytesPerOperation,
+ createScenarioResult,
+ printScenarioResult,
+ readPositiveIntegerEnv,
+ writeScenarioResult,
+} from '../../../../tools/result-utils.ts'
+import type { MemorySample } from '../../../../tools/result-utils.ts'
+
+interface StartRequestHandler {
+ fetch: (request: Request) => Promise | Response
+}
+
+const scenario = {
+ id: 'react.ssr.repeated-requests',
+ label: 'React SSR repeated requests',
+ framework: 'react',
+ runtime: 'ssr',
+ scenario: 'repeated-requests',
+}
+
+const requestInit = {
+ method: 'GET',
+ headers: {
+ accept: 'text/html',
+ },
+} satisfies RequestInit
+
+function requestUrlForIteration(iteration: number) {
+ const slot = iteration % 32
+ const q = `q-${slot}`
+ const page = slot % 8
+
+ if (slot % 4 === 0) {
+ return `http://localhost/a-${slot}/b-${slot}/c-${slot}/d-${slot}?q=${q}&page=${page}&filter=memory`
+ }
+
+ if (slot % 4 === 1) {
+ return `http://localhost/a-${slot}/b-${slot}/c-${slot}/d-next?filter=memory&page=${page}&q=${q}`
+ }
+
+ if (slot % 4 === 2) {
+ return `http://localhost/a-${slot}/b-next/c-${slot}/d-${slot}?page=${page}&filter=memory&q=${q}`
+ }
+
+ return `http://localhost/a-next/b-${slot}/c-${slot}/d-${slot}?q=${q}&filter=memory&page=${page}`
+}
+
+async function runRequests(
+ handler: StartRequestHandler,
+ count: number,
+ startIteration: number,
+) {
+ for (let index = 0; index < count; index++) {
+ const iteration = startIteration + index
+ const requestUrl = requestUrlForIteration(iteration)
+ const response = await handler.fetch(new Request(requestUrl, requestInit))
+
+ if (response.status !== 200) {
+ throw new Error(
+ `Request failed with non-200 status ${response.status} (${requestUrl})`,
+ )
+ }
+
+ await response.text()
+ }
+}
+
+function forceGc() {
+ const gc = (globalThis as typeof globalThis & { gc?: () => void }).gc
+ if (typeof gc !== 'function') {
+ throw new Error('SSR memory benchmarks must run with node --expose-gc')
+ }
+
+ for (let index = 0; index < 6; index++) {
+ gc()
+ }
+}
+
+async function settlePendingWork(ticks: number) {
+ for (let index = 0; index < ticks; index++) {
+ await new Promise((resolve) => setImmediate(resolve))
+ }
+}
+
+function readNodeMemory() {
+ const memory = process.memoryUsage()
+
+ return {
+ rssBytes: memory.rss,
+ heapUsedBytes: memory.heapUsed,
+ heapTotalBytes: memory.heapTotal,
+ externalBytes: memory.external,
+ arrayBuffersBytes: memory.arrayBuffers,
+ }
+}
+
+const startedAt = new Date()
+const iterations = readPositiveIntegerEnv('MEMORY_BENCH_ITERATIONS', 1000)
+const warmupIterations = readPositiveIntegerEnv(
+ 'MEMORY_BENCH_WARMUP_ITERATIONS',
+ 100,
+)
+const batchSize = readPositiveIntegerEnv('MEMORY_BENCH_BATCH_SIZE', 50)
+const settleTicks = readPositiveIntegerEnv('MEMORY_BENCH_SETTLE_TICKS', 5)
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+const { default: handler } = (await import(appModuleUrl)) as {
+ default: StartRequestHandler
+}
+
+await runRequests(handler, warmupIterations, 0)
+await settlePendingWork(settleTicks)
+forceGc()
+
+const baseline = readNodeMemory()
+const samples: Array = []
+let peakRssBytes = baseline.rssBytes
+let peakHeapUsedBytes = baseline.heapUsedBytes
+let peakExternalBytes = baseline.externalBytes
+let peakArrayBuffersBytes = baseline.arrayBuffersBytes
+
+for (let completed = 0; completed < iterations; completed += batchSize) {
+ const nextBatchSize = Math.min(batchSize, iterations - completed)
+ await runRequests(handler, nextBatchSize, warmupIterations + completed)
+
+ const beforeGc = readNodeMemory()
+ peakRssBytes = Math.max(peakRssBytes, beforeGc.rssBytes)
+ peakHeapUsedBytes = Math.max(peakHeapUsedBytes, beforeGc.heapUsedBytes)
+ peakExternalBytes = Math.max(peakExternalBytes, beforeGc.externalBytes)
+ peakArrayBuffersBytes = Math.max(
+ peakArrayBuffersBytes,
+ beforeGc.arrayBuffersBytes,
+ )
+
+ await settlePendingWork(settleTicks)
+ forceGc()
+ const afterGc = readNodeMemory()
+ const iteration = completed + nextBatchSize
+ samples.push({
+ iteration,
+ rssBytes: afterGc.rssBytes,
+ heapUsedBytes: afterGc.heapUsedBytes,
+ heapTotalBytes: afterGc.heapTotalBytes,
+ retainedHeapDeltaBytes: afterGc.heapUsedBytes - baseline.heapUsedBytes,
+ externalBytes: afterGc.externalBytes,
+ arrayBuffersBytes: afterGc.arrayBuffersBytes,
+ peakRssBytes,
+ peakHeapUsedBytes,
+ peakExternalBytes,
+ peakArrayBuffersBytes,
+ })
+}
+
+const finalSample = samples[samples.length - 1]!
+const retainedHeapDeltaBytes =
+ finalSample.heapUsedBytes - baseline.heapUsedBytes
+const retainedHeapBytesPerOperation = retainedHeapDeltaBytes / iterations
+const retainedHeapSlopeBytesPerOperation = computeSlopeBytesPerOperation(
+ samples,
+ (sample) => sample.retainedHeapDeltaBytes,
+)
+const result = createScenarioResult({
+ ...scenario,
+ startedAt,
+ config: {
+ iterations,
+ warmupIterations,
+ batchSize,
+ settleTicks,
+ },
+ metrics: {
+ baselineRssBytes: baseline.rssBytes,
+ finalRssBytes: finalSample.rssBytes,
+ peakRssBytes,
+ baselineHeapUsedBytes: baseline.heapUsedBytes,
+ finalHeapUsedBytes: finalSample.heapUsedBytes,
+ retainedHeapDeltaBytes,
+ retainedHeapBytesPerOperation,
+ retainedHeapSlopeBytesPerOperation,
+ peakHeapUsedBytes,
+ baselineExternalBytes: baseline.externalBytes,
+ finalExternalBytes: finalSample.externalBytes,
+ peakExternalBytes,
+ baselineArrayBuffersBytes: baseline.arrayBuffersBytes,
+ finalArrayBuffersBytes: finalSample.arrayBuffersBytes,
+ peakArrayBuffersBytes,
+ },
+ samples,
+})
+
+writeScenarioResult(result)
+printScenarioResult(result)
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routeTree.gen.ts b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routeTree.gen.ts
new file mode 100644
index 0000000000..9f110bba31
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routeTree.gen.ts
@@ -0,0 +1,146 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as ARouteImport } from './routes/$a'
+import { Route as ABRouteImport } from './routes/$a.$b'
+import { Route as ABCRouteImport } from './routes/$a.$b.$c'
+import { Route as ABCDRouteImport } from './routes/$a.$b.$c.$d'
+
+const ARoute = ARouteImport.update({
+ id: '/$a',
+ path: '/$a',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const ABRoute = ABRouteImport.update({
+ id: '/$b',
+ path: '/$b',
+ getParentRoute: () => ARoute,
+} as any)
+const ABCRoute = ABCRouteImport.update({
+ id: '/$c',
+ path: '/$c',
+ getParentRoute: () => ABRoute,
+} as any)
+const ABCDRoute = ABCDRouteImport.update({
+ id: '/$d',
+ path: '/$d',
+ getParentRoute: () => ABCRoute,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/$a': typeof ARouteWithChildren
+ '/$a/$b': typeof ABRouteWithChildren
+ '/$a/$b/$c': typeof ABCRouteWithChildren
+ '/$a/$b/$c/$d': typeof ABCDRoute
+}
+export interface FileRoutesByTo {
+ '/$a': typeof ARouteWithChildren
+ '/$a/$b': typeof ABRouteWithChildren
+ '/$a/$b/$c': typeof ABCRouteWithChildren
+ '/$a/$b/$c/$d': typeof ABCDRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/$a': typeof ARouteWithChildren
+ '/$a/$b': typeof ABRouteWithChildren
+ '/$a/$b/$c': typeof ABCRouteWithChildren
+ '/$a/$b/$c/$d': typeof ABCDRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/$a' | '/$a/$b' | '/$a/$b/$c' | '/$a/$b/$c/$d'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/$a' | '/$a/$b' | '/$a/$b/$c' | '/$a/$b/$c/$d'
+ id: '__root__' | '/$a' | '/$a/$b' | '/$a/$b/$c' | '/$a/$b/$c/$d'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ ARoute: typeof ARouteWithChildren
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/$a': {
+ id: '/$a'
+ path: '/$a'
+ fullPath: '/$a'
+ preLoaderRoute: typeof ARouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/$a/$b': {
+ id: '/$a/$b'
+ path: '/$b'
+ fullPath: '/$a/$b'
+ preLoaderRoute: typeof ABRouteImport
+ parentRoute: typeof ARoute
+ }
+ '/$a/$b/$c': {
+ id: '/$a/$b/$c'
+ path: '/$c'
+ fullPath: '/$a/$b/$c'
+ preLoaderRoute: typeof ABCRouteImport
+ parentRoute: typeof ABRoute
+ }
+ '/$a/$b/$c/$d': {
+ id: '/$a/$b/$c/$d'
+ path: '/$d'
+ fullPath: '/$a/$b/$c/$d'
+ preLoaderRoute: typeof ABCDRouteImport
+ parentRoute: typeof ABCRoute
+ }
+ }
+}
+
+interface ABCRouteChildren {
+ ABCDRoute: typeof ABCDRoute
+}
+
+const ABCRouteChildren: ABCRouteChildren = {
+ ABCDRoute: ABCDRoute,
+}
+
+const ABCRouteWithChildren = ABCRoute._addFileChildren(ABCRouteChildren)
+
+interface ABRouteChildren {
+ ABCRoute: typeof ABCRouteWithChildren
+}
+
+const ABRouteChildren: ABRouteChildren = {
+ ABCRoute: ABCRouteWithChildren,
+}
+
+const ABRouteWithChildren = ABRoute._addFileChildren(ABRouteChildren)
+
+interface ARouteChildren {
+ ABRoute: typeof ABRouteWithChildren
+}
+
+const ARouteChildren: ARouteChildren = {
+ ABRoute: ABRouteWithChildren,
+}
+
+const ARouteWithChildren = ARoute._addFileChildren(ARouteChildren)
+
+const rootRouteChildren: RootRouteChildren = {
+ ARoute: ARouteWithChildren,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/router.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/router.tsx
new file mode 100644
index 0000000000..7c4eb0babe
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/router.tsx
@@ -0,0 +1,16 @@
+import { createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ defaultPreload: false,
+ scrollRestoration: false,
+ })
+}
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: ReturnType
+ }
+}
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.$d.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.$d.tsx
new file mode 100644
index 0000000000..d90ef22e09
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.$d.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { RouteWorkload } from '../workload'
+
+export const Route = createFileRoute('/$a/$b/$c/$d')({
+ component: RouteWorkload,
+})
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.tsx
new file mode 100644
index 0000000000..4af8ef800d
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.$c.tsx
@@ -0,0 +1,5 @@
+import { Outlet, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/$a/$b/$c')({
+ component: Outlet,
+})
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.tsx
new file mode 100644
index 0000000000..970118ea15
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.$b.tsx
@@ -0,0 +1,5 @@
+import { Outlet, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/$a/$b')({
+ component: Outlet,
+})
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.tsx
new file mode 100644
index 0000000000..69d1483b77
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/$a.tsx
@@ -0,0 +1,5 @@
+import { Outlet, createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/$a')({
+ component: Outlet,
+})
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/__root.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/__root.tsx
new file mode 100644
index 0000000000..dbc9e2fcdb
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/routes/__root.tsx
@@ -0,0 +1,41 @@
+import {
+ HeadContent,
+ Outlet,
+ Scripts,
+ createRootRoute,
+} from '@tanstack/react-router'
+
+type SearchState = {
+ filter?: string
+ page?: number
+ q?: string
+}
+
+function validateSearch(search: Record): SearchState {
+ const page = Number(search.page)
+
+ return {
+ filter: typeof search.filter === 'string' ? search.filter : undefined,
+ page: Number.isFinite(page) ? page : undefined,
+ q: typeof search.q === 'string' ? search.q : undefined,
+ }
+}
+
+export const Route = createRootRoute({
+ component: RootComponent,
+ validateSearch,
+})
+
+function RootComponent() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/workload.tsx b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/workload.tsx
new file mode 100644
index 0000000000..96ff488ab9
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/src/workload.tsx
@@ -0,0 +1,65 @@
+import { Link, useParams, useSearch } from '@tanstack/react-router'
+
+const probes = Array.from({ length: 24 }, (_, index) => index)
+
+function ParamsProbe({ index }: { index: number }) {
+ const params = useParams({
+ strict: false,
+ })
+
+ return (
+
+ {index}:{String(params.a ?? '')}/{String(params.d ?? '')}
+
+ )
+}
+
+function SearchProbe({ index }: { index: number }) {
+ const search = useSearch({
+ strict: false,
+ })
+
+ return (
+
+ {index}:{String(search.q ?? search.filter ?? search.page ?? '')}
+
+ )
+}
+
+function LinkProbe({ salt }: { salt: number }) {
+ const value = String((salt % 97) + 1)
+
+ return (
+
+ Link {value}
+
+ )
+}
+
+export function RouteWorkload() {
+ return (
+
+ {probes.map((probe) => (
+
+ ))}
+ {probes.map((probe) => (
+
+ ))}
+
+
+ )
+}
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/tsconfig.json b/benchmarks/memory/scenarios/react/ssr/repeated-requests/tsconfig.json
new file mode 100644
index 0000000000..3fcb59196e
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "include": ["src/**/*.ts", "src/**/*.tsx", "bench.ts", "vite.config.ts"]
+}
diff --git a/benchmarks/memory/scenarios/react/ssr/repeated-requests/vite.config.ts b/benchmarks/memory/scenarios/react/ssr/repeated-requests/vite.config.ts
new file mode 100644
index 0000000000..1c999f2d92
--- /dev/null
+++ b/benchmarks/memory/scenarios/react/ssr/repeated-requests/vite.config.ts
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vite'
+import { tanstackStart } from '@tanstack/react-start/plugin/vite'
+import react from '@vitejs/plugin-react'
+
+const rootDir = fileURLToPath(new URL('.', import.meta.url))
+
+export default defineConfig({
+ root: rootDir,
+ plugins: [
+ tanstackStart({
+ srcDirectory: 'src',
+ }),
+ react(),
+ ],
+ build: {
+ outDir: './dist',
+ emptyOutDir: true,
+ minify: false,
+ },
+})
diff --git a/benchmarks/memory/tools/report.ts b/benchmarks/memory/tools/report.ts
new file mode 100644
index 0000000000..1695735f4d
--- /dev/null
+++ b/benchmarks/memory/tools/report.ts
@@ -0,0 +1,46 @@
+#!/usr/bin/env node
+
+import path from 'node:path'
+import {
+ benchmarkName,
+ getGitInfo,
+ getProjectRoot,
+ printScenarioResult,
+ readScenarioResults,
+ schemaVersion,
+ toBenchmarkAction,
+ writeJson,
+} from './result-utils.ts'
+
+const projectRoot = getProjectRoot()
+const results = readScenarioResults()
+
+if (results.length === 0) {
+ throw new Error('No memory benchmark scenario results found')
+}
+
+const generatedAt = new Date().toISOString()
+const git = getGitInfo()
+const current = {
+ schemaVersion,
+ benchmarkName,
+ generatedAt,
+ sha: git.sha,
+ status: {
+ state: 'success',
+ command: 'pnpm nx run @benchmarks/memory:report',
+ git,
+ },
+ metrics: results,
+}
+
+writeJson(path.join(projectRoot, 'results', 'current.json'), current)
+writeJson(
+ path.join(projectRoot, 'results', 'benchmark-action.json'),
+ toBenchmarkAction(results),
+)
+
+process.stdout.write('Memory Benchmarks\n')
+for (const result of results) {
+ printScenarioResult(result)
+}
diff --git a/benchmarks/memory/tools/result-utils.ts b/benchmarks/memory/tools/result-utils.ts
new file mode 100644
index 0000000000..91cfeda68c
--- /dev/null
+++ b/benchmarks/memory/tools/result-utils.ts
@@ -0,0 +1,299 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { execFileSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+
+export const schemaVersion = 1
+export const benchmarkName = 'Memory Usage (retained heap)'
+
+export interface MemorySample {
+ iteration: number
+ [key: string]: number
+}
+
+export interface ScenarioConfig {
+ iterations: number
+ warmupIterations: number
+ batchSize: number
+ [key: string]: number
+}
+
+export interface ScenarioMetrics {
+ retainedHeapDeltaBytes: number
+ retainedHeapBytesPerOperation: number
+ retainedHeapSlopeBytesPerOperation: number
+ peakHeapUsedBytes: number
+ peakRssBytes?: number
+ peakExternalBytes?: number
+ domNodeDelta?: number
+ jsEventListenerDelta?: number
+ [key: string]: number | undefined
+}
+
+export interface ScenarioResult {
+ schemaVersion: number
+ id: string
+ label: string
+ framework: string
+ runtime: string
+ scenario: string
+ measuredAt: string
+ generatedAt: string
+ durationMs: number
+ config: ScenarioConfig
+ metrics: ScenarioMetrics
+ samples: Array
+}
+
+export interface GitInfo {
+ sha?: string
+ branch?: string
+ dirty: boolean
+}
+
+const projectRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '..',
+)
+const scenarioResultsDir = path.join(projectRoot, 'results', 'scenarios')
+
+const intFormat = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 0,
+})
+
+const fixedFormat = new Intl.NumberFormat('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+})
+
+export function readPositiveIntegerEnv(name: string, fallback: number) {
+ const raw = process.env[name]
+ if (!raw) {
+ return fallback
+ }
+
+ const value = Number.parseInt(raw, 10)
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new Error(`${name} must be a positive integer, received ${raw}`)
+ }
+
+ return value
+}
+
+export function readScenarioResults(): Array {
+ if (!fs.existsSync(scenarioResultsDir)) {
+ return []
+ }
+
+ return fs
+ .readdirSync(scenarioResultsDir)
+ .filter((fileName) => fileName.endsWith('.json'))
+ .map((fileName) => {
+ const filePath = path.join(scenarioResultsDir, fileName)
+ return JSON.parse(fs.readFileSync(filePath, 'utf8')) as ScenarioResult
+ })
+ .sort((a, b) => a.id.localeCompare(b.id))
+}
+
+export function writeScenarioResult(result: ScenarioResult) {
+ fs.mkdirSync(scenarioResultsDir, { recursive: true })
+
+ const filePath = path.join(scenarioResultsDir, `${result.id}.json`)
+ fs.writeFileSync(filePath, `${JSON.stringify(result, null, 2)}\n`)
+ return filePath
+}
+
+export function createScenarioResult({
+ id,
+ label,
+ framework,
+ runtime,
+ scenario,
+ config,
+ startedAt,
+ metrics,
+ samples,
+}: {
+ id: string
+ label: string
+ framework: string
+ runtime: string
+ scenario: string
+ config: ScenarioConfig
+ startedAt: Date
+ metrics: ScenarioMetrics
+ samples: Array
+}): ScenarioResult {
+ return {
+ schemaVersion,
+ id,
+ label,
+ framework,
+ runtime,
+ scenario,
+ measuredAt: startedAt.toISOString(),
+ generatedAt: new Date().toISOString(),
+ durationMs: Date.now() - startedAt.getTime(),
+ config,
+ metrics,
+ samples,
+ }
+}
+
+export function computeSlopeBytesPerOperation(
+ samples: Array,
+ getBytes: (sample: TSample) => number,
+) {
+ const points = samples
+ .map((sample) => ({
+ x: sample.iteration,
+ y: getBytes(sample),
+ }))
+ .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
+
+ if (points.length < 2) {
+ return 0
+ }
+
+ const meanX = points.reduce((sum, point) => sum + point.x, 0) / points.length
+ const meanY = points.reduce((sum, point) => sum + point.y, 0) / points.length
+
+ let numerator = 0
+ let denominator = 0
+ for (const point of points) {
+ const dx = point.x - meanX
+ numerator += dx * (point.y - meanY)
+ denominator += dx * dx
+ }
+
+ return denominator === 0 ? 0 : numerator / denominator
+}
+
+export function formatBytes(
+ bytes: number | undefined,
+ opts: { signed?: boolean } = {},
+) {
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes)) {
+ return 'n/a'
+ }
+
+ const signed = opts.signed === true
+ const sign = signed && bytes !== 0 ? (bytes > 0 ? '+' : '-') : ''
+ const absBytes = Math.abs(bytes)
+
+ if (absBytes < 1024) {
+ return `${sign}${intFormat.format(absBytes)} B`
+ }
+
+ const kib = absBytes / 1024
+ if (kib < 1024) {
+ return `${sign}${fixedFormat.format(kib)} KiB`
+ }
+
+ return `${sign}${fixedFormat.format(kib / 1024)} MiB`
+}
+
+function formatNumber(
+ value: number | undefined,
+ opts: { signed?: boolean } = {},
+) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return 'n/a'
+ }
+
+ const signed = opts.signed === true
+ const sign = signed && value !== 0 ? (value > 0 ? '+' : '-') : ''
+ return `${sign}${intFormat.format(Math.abs(value))}`
+}
+
+export function printScenarioResult(result: ScenarioResult) {
+ const { metrics } = result
+ const lines = [
+ '',
+ result.id,
+ ` iterations ${formatNumber(result.config.iterations)}`,
+ ` warmup iterations ${formatNumber(result.config.warmupIterations)}`,
+ ` retained heap delta ${formatBytes(metrics.retainedHeapDeltaBytes, { signed: true })}`,
+ ` retained heap / op ${formatBytes(metrics.retainedHeapBytesPerOperation)}/op`,
+ ` retained heap slope ${formatBytes(metrics.retainedHeapSlopeBytesPerOperation, { signed: true })}/op`,
+ ` peak heap used ${formatBytes(metrics.peakHeapUsedBytes)}`,
+ ]
+
+ if (Number.isFinite(metrics.peakRssBytes)) {
+ lines.push(
+ ` peak rss ${formatBytes(metrics.peakRssBytes)}`,
+ )
+ }
+
+ if (Number.isFinite(metrics.peakExternalBytes)) {
+ lines.push(
+ ` peak external ${formatBytes(metrics.peakExternalBytes)}`,
+ )
+ }
+
+ if (Number.isFinite(metrics.domNodeDelta)) {
+ lines.push(
+ ` DOM node delta ${formatNumber(metrics.domNodeDelta, { signed: true })}`,
+ )
+ }
+
+ if (Number.isFinite(metrics.jsEventListenerDelta)) {
+ lines.push(
+ ` JS event listener delta ${formatNumber(metrics.jsEventListenerDelta, { signed: true })}`,
+ )
+ }
+
+ lines.push('')
+ process.stdout.write(`${lines.join('\n')}\n`)
+}
+
+export function getGitInfo(): GitInfo {
+ const git = (args: Array) => {
+ try {
+ return execFileSync('git', args, {
+ cwd: projectRoot,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ }).trim()
+ } catch {
+ return undefined
+ }
+ }
+
+ const sha = git(['rev-parse', 'HEAD'])
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'])
+ const dirty = Boolean(git(['status', '--porcelain']))
+
+ return {
+ sha,
+ branch,
+ dirty,
+ }
+}
+
+export function toBenchmarkAction(results: Array) {
+ return results.map((result) => ({
+ name: result.id,
+ unit: 'bytes',
+ value: Math.max(0, Math.round(result.metrics.retainedHeapDeltaBytes)),
+ extra: [
+ `slope=${Math.round(result.metrics.retainedHeapSlopeBytesPerOperation)} B/op`,
+ `peak_heap=${Math.round(result.metrics.peakHeapUsedBytes)} B`,
+ typeof result.metrics.peakRssBytes === 'number' &&
+ Number.isFinite(result.metrics.peakRssBytes)
+ ? `peak_rss=${Math.round(result.metrics.peakRssBytes)} B`
+ : undefined,
+ ]
+ .filter(Boolean)
+ .join('; '),
+ }))
+}
+
+export function writeJson(filePath: string, value: unknown) {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
+}
+
+export function getProjectRoot() {
+ return projectRoot
+}
diff --git a/benchmarks/memory/tools/summary.ts b/benchmarks/memory/tools/summary.ts
new file mode 100644
index 0000000000..1f4678d60b
--- /dev/null
+++ b/benchmarks/memory/tools/summary.ts
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+
+import { parseArgs } from 'node:util'
+import { printScenarioResult, readScenarioResults } from './result-utils.ts'
+
+const { values } = parseArgs({
+ args: process.argv.slice(2),
+ allowPositionals: false,
+ strict: false,
+ options: {
+ scope: { type: 'string' },
+ },
+})
+
+const scope = values.scope
+const results = readScenarioResults().filter((result) => {
+ return !scope || result.id === scope || result.id.startsWith(`${scope}.`)
+})
+
+if (results.length === 0) {
+ process.stdout.write(
+ scope
+ ? `No memory benchmark results found for ${scope}.\n`
+ : 'No memory benchmark results found.\n',
+ )
+ process.exit(0)
+}
+
+process.stdout.write('Memory Benchmarks\n')
+for (const result of results) {
+ printScenarioResult(result)
+}
diff --git a/benchmarks/memory/tools/tsconfig.json b/benchmarks/memory/tools/tsconfig.json
new file mode 100644
index 0000000000..e6f83d5e30
--- /dev/null
+++ b/benchmarks/memory/tools/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../tsconfig.json",
+ "include": ["*.ts"]
+}
diff --git a/benchmarks/memory/tsconfig.json b/benchmarks/memory/tsconfig.json
new file mode 100644
index 0000000000..231a7a11bf
--- /dev/null
+++ b/benchmarks/memory/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "allowJs": true,
+ "allowImportingTsExtensions": true,
+ "skipLibCheck": true,
+ "types": ["node", "vite/client"]
+ },
+ "exclude": ["node_modules", "**/dist"]
+}
diff --git a/package.json b/package.json
index 742fe4a82c..1b1085ad92 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,8 @@
"benchmark:bundle-size:history": "node scripts/benchmarks/bundle-size/history.mjs",
"benchmark:bundle-size:analyze": "node scripts/benchmarks/bundle-size/analyze.mjs",
"benchmark:client-nav": "pnpm nx run @benchmarks/client-nav:test:perf",
+ "benchmark:memory": "pnpm nx run @benchmarks/memory:test:memory",
+ "benchmark:memory:react": "pnpm nx run @benchmarks/memory:test:memory:react",
"benchmark:ssr": "pnpm nx run @benchmarks/ssr:test:perf",
"build": "nx affected --target=build --exclude=e2e/** --exclude=examples/**",
"build:all": "nx run-many --target=build --exclude=examples/** --exclude=e2e/**",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7938cd51f4..b52d41ea8a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -313,6 +313,43 @@ importers:
specifier: ^4.1.4
version: 4.1.4(@types/node@25.0.9)(@vitest/ui@4.1.4)(jsdom@27.0.0(postcss@8.5.15))(msw@2.7.0(@types/node@25.0.9)(typescript@6.0.2))(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
+ benchmarks/memory:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../packages/react-router
+ '@tanstack/react-start':
+ specifier: workspace:*
+ version: link:../../packages/react-start
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.57.0
+ version: 1.58.0
+ '@types/node':
+ specifier: 25.0.9
+ version: 25.0.9
+ '@types/react':
+ specifier: ^19.2.8
+ version: 19.2.9
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.9)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
+ typescript:
+ specifier: ^6.0.2
+ version: 6.0.2
+ vite:
+ specifier: ^8.0.14
+ version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)
+
benchmarks/ssr:
dependencies:
'@tanstack/react-router':