Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 22 additions & 1 deletion .github/workflows/client-nav-benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ env:

jobs:
client-nav-benchmarks:
name: Run Client Nav Benchmarks
name: Run Client Nav and SSR Benchmarks
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down Expand Up @@ -47,3 +47,24 @@ jobs:
with:
mode: simulation
run: WITH_INSTRUMENTATION=1 pnpm nx run @benchmarks/client-nav:test:perf:vue

- name: Run CodSpeed SSR benchmark for React
continue-on-error: true
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: WITH_INSTRUMENTATION=1 pnpm nx run @benchmarks/ssr:test:perf:react

- name: Run CodSpeed SSR benchmark for Solid
continue-on-error: true
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: WITH_INSTRUMENTATION=1 pnpm nx run @benchmarks/ssr:test:perf:solid

- name: Run CodSpeed SSR benchmark for Vue
continue-on-error: true
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: WITH_INSTRUMENTATION=1 pnpm nx run @benchmarks/ssr:test:perf:vue
37 changes: 37 additions & 0 deletions benchmarks/ssr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SSR Benchmarks

Cross-framework SSR request-loop benchmarks for:

- `@tanstack/react-start`
- `@tanstack/solid-start`
- `@tanstack/vue-start`

Each benchmark builds a Start app with file-based routes and runs Vitest benches against the built server handler.

## Layout

- `react/` - React Start benchmark + Vitest config
- `solid/` - Solid Start benchmark + Vitest config
- `vue/` - Vue Start benchmark + Vitest config

## Run

Run all benchmarks through Nx so dependency builds are part of the graph:

```bash
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/ssr:test:perf --outputStyle=stream --skipRemoteCache
```

Run framework-specific benchmarks:

```bash
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/ssr:test:perf:react --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/ssr:test:perf:solid --outputStyle=stream --skipRemoteCache
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/ssr:test:perf:vue --outputStyle=stream --skipRemoteCache
```

Typecheck benchmark sources:

```bash
CI=1 NX_DAEMON=false pnpm nx run @benchmarks/ssr:test:types --outputStyle=stream --skipRemoteCache
```
67 changes: 67 additions & 0 deletions benchmarks/ssr/bench-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export interface StartRequestHandler {
fetch: (request: Request) => Promise<Response> | Response
}

export interface RunSsrRequestLoopOptions {
seed: number
iterations?: number
}

const requestInit = {
method: 'GET',
headers: {
accept: 'text/html',
},
} satisfies RequestInit

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

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

function randomSegment(random: () => number) {
return Math.floor(random() * 1_000_000_000).toString(36)
}

function randomSearchValue(random: () => number) {
return `q-${randomSegment(random)}`
}

function randomRequestUrl(random: () => number) {
const a = randomSegment(random)
const b = randomSegment(random)
const c = randomSegment(random)
const d = randomSegment(random)
const q = randomSearchValue(random)

return `http://localhost/${a}/${b}/${c}/${d}?q=${q}`
}

export async function runSsrRequestLoop(
handler: StartRequestHandler,
{ seed, iterations = 10 }: RunSsrRequestLoopOptions,
) {
const random = createDeterministicRandom(seed)
const pendingBodyReads: Array<Promise<void>> = []

for (let index = 0; index < iterations; index++) {
const requestUrl = randomRequestUrl(random)
const response = await handler.fetch(new Request(requestUrl, requestInit))

if (response.status !== 200) {
await Promise.allSettled(pendingBodyReads)

throw new Error(
`Request failed with non-200 status ${response.status} (${requestUrl})`,
)
}

pendingBodyReads.push(response.text().then(() => undefined))
}

await Promise.all(pendingBodyReads)
}
94 changes: 94 additions & 0 deletions benchmarks/ssr/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"name": "@benchmarks/ssr",
"private": true,
"type": "module",
"scripts": {
"build:react": "vite build --config ./react/vite.config.ts",
"build:solid": "vite build --config ./solid/vite.config.ts",
"build:vue": "vite build --config ./vue/vite.config.ts",
"test:perf": "vitest bench",
"test:perf:react": "vitest bench --config ./react/vite.config.ts ./react/speed.bench.ts",
"test:perf:solid": "vitest bench --config ./solid/vite.config.ts ./solid/speed.bench.ts",
"test:perf:vue": "vitest bench --config ./vue/vite.config.ts ./vue/speed.bench.ts",
"test:types": "pnpm run test:types:react && pnpm run test:types:solid && pnpm run test:types:vue",
"test:types:react": "tsc -p ./react/tsconfig.json --noEmit",
"test:types:solid": "tsc -p ./solid/tsconfig.json --noEmit",
"test:types:vue": "tsc -p ./vue/tsconfig.json --noEmit"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
"@tanstack/react-start": "workspace:^",
"@tanstack/solid-router": "workspace:^",
"@tanstack/solid-start": "workspace:^",
"@tanstack/vue-router": "workspace:^",
"@tanstack/vue-start": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"solid-js": "^1.9.10",
"vue": "^3.5.16"
},
"devDependencies": {
"@codspeed/vitest-plugin": "^5.0.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"typescript": "^5.7.2",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.17"
},
"nx": {
"targets": {
"build:react": {
"cache": false,
"dependsOn": [
"^build"
]
},
"build:solid": {
"cache": false,
"dependsOn": [
"^build"
]
},
"build:vue": {
"cache": false,
"dependsOn": [
"^build"
]
},
"test:perf": {
"cache": false,
"dependsOn": [
"^build",
"build:react",
"build:solid",
"build:vue"
]
},
"test:perf:react": {
"cache": false,
"dependsOn": [
"build:react"
]
},
"test:perf:solid": {
"cache": false,
"dependsOn": [
"build:solid"
]
},
"test:perf:vue": {
"cache": false,
"dependsOn": [
"build:vue"
]
},
"test:types": {
"cache": false,
"dependsOn": [
"^build"
]
}
}
}
}
50 changes: 50 additions & 0 deletions benchmarks/ssr/react/speed.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterAll, beforeAll, bench, describe } from 'vitest'
import { runSsrRequestLoop } from '../bench-utils'
import type { StartRequestHandler } from '../bench-utils'

const appModulePath = './dist/server/server.js'
const benchmarkSeed = 0xdecafbad

const uninitializedHandler: StartRequestHandler = {
fetch: () => Promise.reject(new Error('Benchmark not initialized')),
}

let handler = uninitializedHandler

async function setup() {
const module = (await import(appModulePath)) as {
default: StartRequestHandler
}

handler = module.default
}

function teardown() {
handler = uninitializedHandler
}

describe('ssr', () => {
/**
* Running `vitest bench` ignores "suite hooks" like `beforeAll` and `afterAll`,
* so we use tinybench's `setup` and `teardown` options to run our setup and teardown logic.
*
* But CodSpeed calls the benchmarked function directly, bypassing `setup` and `teardown`,
* but it does support `beforeAll` and `afterAll`.
*
* So it looks like we're setting up in duplicate, but in reality, it's only running once per environment, as intended.
*/
beforeAll(setup)
afterAll(teardown)

bench(
'ssr request loop (react)',
() => runSsrRequestLoop(handler, { seed: benchmarkSeed }),
{
warmupIterations: 100,
time: 10_000,
setup,
teardown,
throws: true,
},
)
})
Loading
Loading