Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eab7570
benchmark: memory (codspeed)
Sheraff Jun 12, 2026
3d31b79
fix codspeed github action
Sheraff Jun 12, 2026
4c6d3af
unified codspeed job
Sheraff Jun 12, 2026
025d4b4
im dumb
Sheraff Jun 12, 2026
537776c
cleaner run command in github action
Sheraff Jun 13, 2026
97d1801
better response stream draining
Sheraff Jun 13, 2026
b01f4de
cleanup streaming-peak scenario: we only care about server memory, no…
Sheraff Jun 13, 2026
fa3cb8a
use platformatic/flame for local memory bench
Sheraff Jun 13, 2026
9bd39c0
direct pprof calls for cleaner output
Sheraff Jun 13, 2026
3e63fd2
review
Sheraff Jun 13, 2026
073a036
increase iteration count for low benches
Sheraff Jun 13, 2026
d8a4e76
flame run splits by benches, like codspeed
Sheraff Jun 13, 2026
d1ef03d
ci: apply automated fixes
autofix-ci[bot] Jun 13, 2026
ca04026
Merge branch 'main' into bench-codspeed-memory
Sheraff Jun 13, 2026
2ff1a0d
QA
Sheraff Jun 13, 2026
92c2688
build outside of codspeed instrumentation
Sheraff Jun 13, 2026
fab18e6
ci: apply automated fixes
autofix-ci[bot] Jun 13, 2026
6a4c1f6
nitpick
Sheraff Jun 13, 2026
a30c37e
fix workspace deps
Sheraff Jun 13, 2026
ed51981
Merge branch 'main' into bench-codspeed-memory
Sheraff Jun 13, 2026
cbfde6d
solid & vue
Sheraff Jun 13, 2026
ab7fb18
ci: apply automated fixes
autofix-ci[bot] Jun 13, 2026
8d25db9
simplify dual architecture
Sheraff Jun 13, 2026
b15de72
cleanup
Sheraff Jun 13, 2026
c922c2a
ci: apply automated fixes
autofix-ci[bot] Jun 13, 2026
e4fbeb1
fix vue benchmark
Sheraff Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/memory-benchmarks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Setup taken from https://codspeed.io/docs/benchmarks/nodejs/vitest
name: Memory Benchmarks

on:
push:
branches:
- 'main'
paths:
- 'packages/**'
- 'benchmarks/**'
pull_request:
paths:
- 'packages/**'
- 'benchmarks/**'
workflow_dispatch:

permissions:
contents: read # required for actions/checkout
id-token: write # required for OIDC authentication with CodSpeed

env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
SERVER_PRESET: 'node-server'
NX_NO_CLOUD: true

jobs:
benchmarks:
name: Run ${{ matrix.benchmark }} CodSpeed benchmark
strategy:
fail-fast: false
matrix:
benchmark:
- memory-server
- memory-client
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Setup Tools
uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main

- name: Run ${{ matrix.benchmark }} CodSpeed benchmark
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
with:
mode: memory
run: WITH_INSTRUMENTATION=1 pnpm nx run @benchmarks/${{ matrix.benchmark }}:test:perf:react
122 changes: 122 additions & 0 deletions benchmarks/memory/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Memory Benchmarks

Dedicated memory benchmarks for TanStack Router / Start, measured with the
CodSpeed **memory instrument** (`mode: memory` in
`.github/workflows/memory-benchmarks.yml`). Two separate benchmarks:

- `server/` (`@benchmarks/memory-server`) — React Start apps, requests against
the built server handler (`handler.fetch`), Node environment.
- `client/` (`@benchmarks/memory-client`) — router-only React apps in jsdom.

These deliberately do **not** reuse the CPU scenarios in `benchmarks/ssr` and
`benchmarks/client-nav`: memory benches need their own iteration counts,
payload sizes, and route shapes, and tuning those must never shift the CPU
baselines. React-first; each scenario keeps a `react/` level so solid/vue can
be added later without renames.

## Layout

```text
benchmarks/memory/<server|client>/
package.json Nx targets: build:react, test:perf:react, test:types
bench-utils.ts memoryBenchOptions, seeded LCG (+ sequential request loop on the server side)
vitest.react.config.ts aggregates scenarios/*/react/vite.config.ts
scenarios/<scenario>/react/ one isolated app per scenario + memory.bench.ts
```

One app per scenario; apps and bench names are stable once landed (CodSpeed
continuity). Never grow an existing scenario for a new case — add a scenario.

## How the memory instrument executes a bench

- The bench function is warmed up, then **measured exactly once**, starting
after a forced GC. Under plain `vitest bench` the suites only smoke-test:
timing output is meaningless; real numbers come from CodSpeed.
- Under CodSpeed the bench fn runs several warmup invocations plus the
measured one **on the same mount**, so bench fns must be idempotent and
module-level counters/LCGs are used where ids must never repeat across
invocations.
- Plain `vitest bench` never runs suite hooks (`beforeAll`/`afterAll`) and
only honors tinybench's `setup`/`teardown` options; the CodSpeed runner
does the exact opposite. Client benches therefore register **both** — in
any given mode exactly one pair runs.
- The process runs with V8 determinism flags (predictable GC schedule,
`--no-opt`). Never call `global.gc()` manually. Because of `--no-opt`,
allocation counts overstate production; numbers are for regression
tracking, not absolute claims.
- Keep each bench under **~1.5M allocations** (instrument overhead grows past
2M); this is the main constraint when tuning iteration counts.

## Bench shapes and signals

- **Churn (leak detector):** N sequential iterations at steady state. If one
iteration leaks L bytes, peak grows by ~N·L; healthy builds show a flat
timeline floor independent of N. Tuning check: doubling N must leave peak
roughly unchanged.
- **Peak (footprint):** one (or very few) large operations; peak memory
scaling with the workload is the signal.

## Scenarios

### Server

| Scenario | Shape | Guards against |
| ----------------------- | ----- | --------------------------------------------------------- |
| `request-churn` | churn | cross-request retention in document SSR (unique URLs) |
| `server-fn-churn` | churn | retention in the server-function RPC path |
| `error-paths` | churn | redirect/notFound/error/unmatched paths pinning contexts |
| `aborted-requests` | churn | dangling streams/listeners after mid-stream client aborts |
| `peak-large-page` | peak | per-request peak scaling with page size |
| `streaming-peak` | peak | streaming buffering O(document) instead of O(chunk) |
| `serialization-payload` | peak | double-buffering / string-copy blowups in dehydration |

### Client

| Scenario | Shape | Guards against |
| ------------------------- | ----- | -------------------------------------------------------- |
| `navigation-churn` | churn | per-navigation retention at steady state |
| `unique-location-churn` | churn | unbounded href/search-keyed caches (never-repeated URLs) |
| `preload-churn` | churn | preload-cache eviction not releasing memory |
| `loader-data-retention` | churn | departed routes' loader data staying pinned (gcTime 0) |
| `mount-unmount` | churn | router instances not collectable after dispose |
| `interrupted-navigations` | churn | superseded navigations retaining closures/contexts |

## Conventions

- Strictly sequential work: at most one request/navigation in flight; each
server response is fully consumed before the next request. Pairing a single
navigation with its render signal via `Promise.all([navigate, rendered])`
is fine — never overlap distinct work items.
- Randomness only via the seeded LCG in `bench-utils.ts`; no `Math.random`,
`Date.now`, or timers — with one documented exception: `streaming-peak`'s
deferred sections use small `setTimeout` delays, because React schedules
stream flushes via `setImmediate` and any non-timer deferral wins that race,
suppressing the Suspense fallbacks the scenario exists to stream.
- Sanity assertions run once at module load and throw on wrong
status/markers, so a bench can never silently measure the wrong thing.
- Server requests follow `benchmarks/ssr` conventions: document GETs send
`accept: text/html`, server-fn requests send `sec-fetch-site: same-origin`
with bodies precomputed at module level.
- Client apps export `mountTestApp` from `app.tsx`; benches import the built
`dist/app.js`; navigations use `replace: true`; unmount does full teardown
(React root, `__TSR_ROUTER__`, `history.destroy()`); large loader payloads
are never rendered into the DOM.
- `NODE_ENV=production` everywhere (the Nx targets set it).

## Run

```bash
pnpm nx run @benchmarks/memory-server:test:perf:react --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/memory-client:test:perf:react --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/memory-server:test:types --outputStyle=stream --skipRemoteCache
pnpm nx run @benchmarks/memory-client:test:types --outputStyle=stream --skipRemoteCache
```

Real memory measurement, locally (requires the CodSpeed CLI, `codspeed setup`
once to install the memory executor, and sudo; **uploads results to the
CodSpeed dashboard** — local runs do not affect PR baselines):

```bash
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-server:test:perf:react
WITH_INSTRUMENTATION=1 codspeed run --mode memory -- pnpm nx run @benchmarks/memory-client:test:perf:react
```
19 changes: 19 additions & 0 deletions benchmarks/memory/client/bench-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const memoryBenchOptions = {
iterations: 1,
warmupIterations: 1,
time: 0,
warmupTime: 0,
}

export function createDeterministicRandom(seed: number) {
let state = seed >>> 0

return () => {
state = (state * 1664525 + 1013904223) >>> 0
return state / 0x100000000
}
}

export function randomSegment(random: () => number) {
return Math.floor(random() * 1_000_000_000).toString(36)
}
68 changes: 68 additions & 0 deletions benchmarks/memory/client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "@benchmarks/memory-client",
"private": true,
"type": "module",
"dependencies": {
"@tanstack/react-router": "workspace:^",
"@tanstack/router-core": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@codspeed/vitest-plugin": "^5.5.0",
"@testing-library/react": "^16.2.0",
"@vitejs/plugin-react": "^6.0.1",
"@types/jsdom": "28.0.0",
"typescript": "^6.0.2",
"vite": "^8.0.14",
"vitest": "^4.1.4"
},
"nx": {
"targets": {
"build:react": {
"executor": "nx:noop",
"cache": false,
"dependsOn": [
{
"projects": [
"@benchmarks/memory-client-navigation-churn-react",
"@benchmarks/memory-client-unique-location-churn-react",
"@benchmarks/memory-client-preload-churn-react",
"@benchmarks/memory-client-loader-data-retention-react",
"@benchmarks/memory-client-mount-unmount-react",
"@benchmarks/memory-client-interrupted-navigations-react"
],
"target": "build:client"
}
]
},
"test:perf:react": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": [
"build:react"
],
"options": {
"command": "NODE_ENV=production vitest bench --config ./vitest.react.config.ts",
"cwd": "benchmarks/memory/client"
}
},
"test:types": {
"executor": "nx:noop",
"dependsOn": [
{
"projects": [
"@benchmarks/memory-client-navigation-churn-react",
"@benchmarks/memory-client-unique-location-churn-react",
"@benchmarks/memory-client-preload-churn-react",
"@benchmarks/memory-client-loader-data-retention-react",
"@benchmarks/memory-client-mount-unmount-react",
"@benchmarks/memory-client-interrupted-navigations-react"
],
"target": "test:types:client"
}
]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterAll, beforeAll, bench, describe } from 'vitest'
import {
createDeterministicRandom,
memoryBenchOptions,
randomSegment,
} from '../../../bench-utils'
import { setup } from './setup'

const interruptedNavigationIterations = 150

const interruptedNavigationPairs = createInterruptedNavigationPairs(
interruptedNavigationIterations,
)

function createInterruptedNavigationPairs(iterations: number) {
const random = createDeterministicRandom(13)

return Array.from({ length: iterations }, (_, index) => ({
slowId: `slow-${index}-${randomSegment(random)}`,
fastId: `fast-${index}-${randomSegment(random)}`,
}))
}

await setup().sanity()

describe('memory', () => {
const test = setup()

/**
* Plain `vitest bench` never runs suite hooks (beforeAll/afterAll) and only
* honors tinybench's setup/teardown options; the CodSpeed runner does the
* exact opposite. Both registrations are load-bearing — exactly one pair
* runs in any given mode.
*/
beforeAll(test.before)
afterAll(test.after)

bench(
'mem interrupted-navigations (react)',
async () => {
for (const pair of interruptedNavigationPairs) {
await test.interrupt(pair.slowId, pair.fastId)
}
},
{
...memoryBenchOptions,
setup: test.before,
teardown: test.after,
},
)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@benchmarks/memory-client-interrupted-navigations-react",
"projectType": "application",
"targets": {
"build:client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": [
{
"projects": ["@tanstack/react-router"],
"target": "build"
}
],
"options": {
"command": "NODE_ENV=production vite build --config {projectRoot}/vite.config.ts"
}
},
"test:types:client": {
"executor": "nx:run-commands",
"dependsOn": [
{
"projects": ["@tanstack/react-router"],
"target": "build"
}
],
"options": {
"command": "tsc -p {projectRoot}/tsconfig.json --noEmit"
}
}
}
}
Loading
Loading