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
38 changes: 29 additions & 9 deletions launch/benchmarks/redact-frameworks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,42 @@

Measured October 25, 2026 with the same counter component, controlled input, and CSS in four single-route apps. Each pair changes only the renderer integration. The landing page uses these measurements, with one shared scale for all eight bars.

| Framework | React | Redact | Saved |
| -------------- | -------: | -------: | -------------: |
| TanStack Start | 109.3 KB | 62.3 KB | 47.0 KB, 43.0% |
| Vite | 68.3 KB | 20.1 KB | 48.1 KB, 70.5% |
| Vinext | 138.1 KB | 98.8 KB | 39.3 KB, 28.5% |
| Next.js | 175.6 KB | 139.9 KB | 35.7 KB, 20.4% |
| Framework | React | Redact default | Unused features disabled | Saved by default |
| -------------- | -------: | -------------: | -----------------------: | ---------------: |
| TanStack Start | 109.3 KB | 62.3 KB | 59.8 KB | 47.0 KB, 43.0% |
| Vite | 68.3 KB | 20.1 KB | 12.6 KB | 48.1 KB, 70.5% |
| Vinext | 138.1 KB | 98.8 KB | Not available | 39.3 KB, 28.5% |
| Next.js | 175.6 KB | 139.9 KB | Not available | 35.7 KB, 20.4% |

These are starter app measurements. A larger app has its own routes, dependencies, and chunk layout, so its percentage will differ. The separate [Vinext benchmark](https://github.com/TanStack/redact/blob/main/benchmarks/VINEXT_BENCHMARKS.md) measures a larger workload over six rounds and saves 39.4 KB, or 25.4%.

## What the numbers include

Each file is compressed separately with Node's default `gzipSync`, then the sizes are added. We count all emitted client JavaScript and CSS, including lazy chunks and framework polyfills. KB means 1,000 bytes. These totals are not the download for a single navigation. HTML, inline scripts and Flight payloads, images, source maps, and server output are excluded.

Start and Vite use Redact's default Vite integration. Start sets `ssr.noExternal: true` in both builds. This lets Vite transform React imports inside server dependencies as well as application code. Without it, Node can load an external dependency with native React alongside application code using Redact. Next.js uses `withRedact` with Turbopack. Vinext uses the maintained integration in both modes, with `renderer: 'react'` or `renderer: 'redact'`, so both builds receive the same platform corrections. Next.js and Vinext keep native React on the server. No presets, feature flags, minifier settings, or application code are changed between the two builds of a framework.
Start and Vite use Redact's Vite integration. Start sets `ssr.noExternal: true` in all three builds. This lets Vite transform React imports inside server dependencies as well as application code. Without it, Node can load an external dependency with native React alongside application code using Redact. Vinext uses the maintained integration in both modes, with `renderer: 'react'` or `renderer: 'redact'`, so both builds receive the same platform corrections. Next.js uses `withRedact` with Turbopack. Vinext and Next.js keep native React on the server and use the full Redact runtime, without feature presets. The React and default Redact pairs use default settings. Application code, dependencies, and minifier settings stay the same in every variant.

This is one clean production build per variant, measuring bytes rather than speed. All eight builds were opened in the browser and checked for client startup, a counter update, a controlled input update, and the expected renderer. Start, Vinext, and Next.js were also checked for server-rendered content before JavaScript ran. See [browser checks](./browser-checks.json) and [server checks](./server-checks.json).
This is one clean production build per variant, measuring bytes rather than speed. All ten builds were opened in the browser and checked for client startup, a counter update, a controlled input update, and the expected renderer. All seven Start, Vinext, and Next.js variants were also checked for server-rendered content before JavaScript ran. See [browser checks](./browser-checks.json) and [server checks](./server-checks.json).

## Optional features

The lighter green segment shows the bytes removed by disabling features this demo does not use. The lower endpoints are specific to this app. The headline savings compare the default Redact build with React.

Start disables Activity, Fragment refs, and portals. Hydration, context, Suspense, memoization, forwarded refs, lazy components, and class components, including error boundaries, stay enabled:

```ts
redact({
features: { activity: false, fragmentRefs: false, portal: false },
})
```

The client-rendered Vite counter needs none of the optional features, so its smaller build uses:

```ts
redact({ preset: 'nano' })
```

Keep the features your application and dependencies use. Both configurations are recorded in `results.json` and reproduced by `measure.mjs`. Bar labels round up to whole KB; bar lengths use exact bytes.

## Versions and source

Expand All @@ -40,4 +60,4 @@ node /path/to/measure.mjs "$PWD"
node /path/to/serve.mjs "$PWD"
```

`measure.mjs` generates every fixture from source, builds React then Redact for each framework, records `results.json`, and preserves all eight builds. `serve.mjs` opens local production previews on ports 4380 through 4387, in the order Start, Vite, Vinext, and Next.js, React first in each pair. The small Node HTTP bridge for Start and Vite is only a preview harness and is not part of the measured output.
`measure.mjs` generates every fixture from source, builds React and default Redact for each framework, adds the smaller Start and Vite builds, records `results.json`, and preserves all ten builds. `serve.mjs` opens local production previews on ports 4380 through 4389, in the order Start, Vite, Vinext, and Next.js. Each framework serves React, default Redact, then its smaller build when available. The small Node HTTP bridge for Start and Vite is only a preview harness and is not part of the measured output.
22 changes: 22 additions & 0 deletions launch/benchmarks/redact-frameworks/browser-checks.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@
"renderer identity"
]
},
{
"framework": "Start",
"renderer": "redact-lean",
"passed": true,
"checks": [
"hydration",
"counter update",
"controlled input",
"renderer identity"
]
},
{
"framework": "Vite",
"renderer": "react",
Expand All @@ -45,6 +56,17 @@
"renderer identity"
]
},
{
"framework": "Vite",
"renderer": "redact-lean",
"passed": true,
"checks": [
"client mount",
"counter update",
"controlled input",
"renderer identity"
]
},
{
"framework": "Vinext",
"renderer": "react",
Expand Down
29 changes: 24 additions & 5 deletions launch/benchmarks/redact-frameworks/measure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { gzipSync } from 'node:zlib'
// Run inside an isolated directory with the pinned dependencies installed.
const root = resolve(process.argv[2])
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex')
const leanOptions = {
Start: {
features: { activity: false, fragmentRefs: false, portal: false },
},
Vite: { preset: 'nano' },
}
const counter = `'use client'
import { useEffect, useState } from 'react'

Expand Down Expand Up @@ -50,8 +56,9 @@ const report = {
redactTarballSha256: hash(await readFile(join(root, 'redact.tgz'))),
counterSha256: hash(counter),
cssSha256: hash(css),
leanOptions,
method:
'Same single-route counter and controlled input in all four frameworks. Production client builds with default optimization settings. Start bundles its server dependencies through Vite in both variants so React imports inside dependencies are transformed too. Sum of gzipSync bytes for every emitted client .js and .css file, compressed separately. Includes lazy chunks and framework polyfills. Excludes server output, HTML, inline scripts, maps, and images. Each chart compares the same framework and dependencies, changing only its Redact integration. KB means 1,000 bytes. These are starter apps, not a promise for every app or a browser speed measurement.',
'Same single-route counter and controlled input in all four frameworks. Production client builds with default optimization settings. Start bundles its server dependencies through Vite in every variant so React imports inside dependencies are transformed too. Sum of gzipSync bytes for every emitted client .js and .css file, compressed separately. Includes lazy chunks and framework polyfills. Excludes server output, HTML, inline scripts, maps, and images. Each React/default pair changes only its Redact integration. The extra Start and Vite builds disable unused features with the recorded leanOptions. KB means 1,000 bytes. These are starter apps, not a promise for every app or a browser speed measurement.',
results: [],
}

Expand Down Expand Up @@ -129,7 +136,7 @@ import react from '@vitejs/plugin-react'
import { redact } from '@tanstack/redact/vite'
export default defineConfig({
ssr: { noExternal: true },
plugins: [tanstackStart(), react(), ...(process.env.RENDERER === 'redact' ? [redact()] : [])],
plugins: [tanstackStart(), react(), ...(process.env.RENDERER !== 'react' ? [redact(process.env.RENDERER === 'redact-lean' ? ${JSON.stringify(leanOptions.Start)} : {})] : [])],
})
`,
)
Expand Down Expand Up @@ -167,7 +174,7 @@ export const Route = createFileRoute('/')({ component: Counter })
`import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { redact } from '@tanstack/redact/vite'
export default defineConfig({ plugins: [react(), ...(process.env.RENDERER === 'redact' ? [redact()] : [])] })
export default defineConfig({ plugins: [react(), ...(process.env.RENDERER !== 'react' ? [redact(process.env.RENDERER === 'redact-lean' ? ${JSON.stringify(leanOptions.Vite)} : {})] : [])] })
`,
)
await write(
Expand Down Expand Up @@ -222,7 +229,11 @@ export default process.env.RENDERER === 'redact' ? withRedact(config) : config
}
}

for (const renderer of ['react', 'redact']) {
for (const renderer of [
'react',
'redact',
...(framework in leanOptions ? ['redact-lean'] : []),
]) {
console.log(`Building ${framework} + ${renderer}`)
await run(
app,
Expand Down Expand Up @@ -252,13 +263,21 @@ export default process.env.RENDERER === 'redact' ? withRedact(config) : config
console.log(
`${framework} + ${renderer}: ${result.gzipBytes} gzip bytes across ${emitted.length} files`,
)
// Preserve both complete builds for browser verification after measurement.
// Preserve every complete build for browser verification after measurement.
if (framework !== 'Next.js') {
const { rename } = await import('node:fs/promises')
await rename(join(app, 'dist'), join(app, `dist-${renderer}`))
}
}
}
for (const framework of Object.keys(leanOptions)) {
const variants = report.results.filter((item) => item.framework === framework)
assert(
variants.find((item) => item.renderer === 'redact-lean').gzipBytes <
variants.find((item) => item.renderer === 'redact').gzipBytes,
`${framework}: feature selection did not reduce output`,
)
}
await writeFile(
join(root, 'results.json'),
JSON.stringify(report, null, 2) + '\n',
Expand Down
100 changes: 78 additions & 22 deletions launch/benchmarks/redact-frameworks/results.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"recordedAt": "2026-10-25T17:15:08.787Z",
"recordedAt": "2026-10-25T19:55:07.317Z",
"node": "v24.22.0",
"zlib": "1.3.2.1-motley-285e94b",
"packageVersions": {
Expand All @@ -19,7 +19,19 @@
"redactTarballSha256": "6f8592d92061ab3845b11a5635a2853ec5ba61b600f7ca213cfa42b510ec1099",
"counterSha256": "642b7bf7693281301e2d3b42826b679b40f03cd723be41b0aaea9fa190727ca1",
"cssSha256": "bed31695998ef8641c353a5f356dbadc243b4669edf6fc631fe7b647c1674643",
"method": "Same single-route counter and controlled input in all four frameworks. Production client builds with default optimization settings. Start bundles its server dependencies through Vite in both variants so React imports inside dependencies are transformed too. Sum of gzipSync bytes for every emitted client .js and .css file, compressed separately. Includes lazy chunks and framework polyfills. Excludes server output, HTML, inline scripts, maps, and images. Each chart compares the same framework and dependencies, changing only its Redact integration. KB means 1,000 bytes. These are starter apps, not a promise for every app or a browser speed measurement.",
"leanOptions": {
"Start": {
"features": {
"activity": false,
"fragmentRefs": false,
"portal": false
}
},
"Vite": {
"preset": "nano"
}
},
"method": "Same single-route counter and controlled input in all four frameworks. Production client builds with default optimization settings. Start bundles its server dependencies through Vite in every variant so React imports inside dependencies are transformed too. Sum of gzipSync bytes for every emitted client .js and .css file, compressed separately. Includes lazy chunks and framework polyfills. Excludes server output, HTML, inline scripts, maps, and images. Each React/default pair changes only its Redact integration. The extra Start and Vite builds disable unused features with the recorded leanOptions. KB means 1,000 bytes. These are starter apps, not a promise for every app or a browser speed measurement.",
"results": [
{
"framework": "Start",
Expand Down Expand Up @@ -71,6 +83,31 @@
}
]
},
{
"framework": "Start",
"renderer": "redact-lean",
"gzipBytes": 59778,
"files": [
{
"path": "assets/index-8PKgnleu.js",
"bytes": 167389,
"gzipBytes": 59218,
"sha256": "ee09d42966041a2063e128b854b3485e87389f01db7c510bd62bc547e98eeb01"
},
{
"path": "assets/index-BrYQuRTz.css",
"bytes": 241,
"gzipBytes": 189,
"sha256": "62ea65329a5844dfb94c95ad3d0bb666da2e998f50a88229d86a72d5b72390d4"
},
{
"path": "assets/routes-ieaXzjDW.js",
"bytes": 557,
"gzipBytes": 371,
"sha256": "a055170457c5832e0862d760e476f31e23643c8a644f3fe3c5e64019923f4778"
}
]
},
{
"framework": "Vite",
"renderer": "react",
Expand Down Expand Up @@ -109,19 +146,38 @@
}
]
},
{
"framework": "Vite",
"renderer": "redact-lean",
"gzipBytes": 12590,
"files": [
{
"path": "assets/index-BrYQuRTz.css",
"bytes": 241,
"gzipBytes": 189,
"sha256": "62ea65329a5844dfb94c95ad3d0bb666da2e998f50a88229d86a72d5b72390d4"
},
{
"path": "assets/index-CmDjlZH7.js",
"bytes": 30414,
"gzipBytes": 12401,
"sha256": "c06a9aab10584753ef3dd73ec969b9cda75ceda320ad4aa7610e0bbec32ae3cd"
}
]
},
{
"framework": "Vinext",
"renderer": "react",
"gzipBytes": 138094,
"files": [
{
"path": "_next/static/95aa7be8-9659-4ff0-9197-de663bea07f7/_buildManifest.js",
"path": "_next/static/bb64c4d8-f7ce-4124-a895-fda8315b989e/_buildManifest.js",
"bytes": 176,
"gzipBytes": 139,
"sha256": "4cce301fe69e28d6463904ecf618427e95091bf3584ea347d8ca55d1077e1fb6"
},
{
"path": "_next/static/95aa7be8-9659-4ff0-9197-de663bea07f7/_ssgManifest.js",
"path": "_next/static/bb64c4d8-f7ce-4124-a895-fda8315b989e/_ssgManifest.js",
"bytes": 76,
"gzipBytes": 60,
"sha256": "653f3e53e89b4f8548ff86c19e92bb3c6b84b6be7485a320b1e00893ed877479"
Expand Down Expand Up @@ -181,6 +237,18 @@
"renderer": "redact",
"gzipBytes": 98779,
"files": [
{
"path": "_next/static/11ede8c5-db42-4363-bbd5-eee230bdcf64/_buildManifest.js",
"bytes": 176,
"gzipBytes": 139,
"sha256": "4cce301fe69e28d6463904ecf618427e95091bf3584ea347d8ca55d1077e1fb6"
},
{
"path": "_next/static/11ede8c5-db42-4363-bbd5-eee230bdcf64/_ssgManifest.js",
"bytes": 76,
"gzipBytes": 60,
"sha256": "653f3e53e89b4f8548ff86c19e92bb3c6b84b6be7485a320b1e00893ed877479"
},
{
"path": "_next/static/chunks/BWaJ3C7D.js",
"bytes": 911,
Expand Down Expand Up @@ -222,18 +290,6 @@
"bytes": 241,
"gzipBytes": 189,
"sha256": "62ea65329a5844dfb94c95ad3d0bb666da2e998f50a88229d86a72d5b72390d4"
},
{
"path": "_next/static/d4219925-835c-4b0a-8dbd-ce21fce7e7e1/_buildManifest.js",
"bytes": 176,
"gzipBytes": 139,
"sha256": "4cce301fe69e28d6463904ecf618427e95091bf3584ea347d8ca55d1077e1fb6"
},
{
"path": "_next/static/d4219925-835c-4b0a-8dbd-ce21fce7e7e1/_ssgManifest.js",
"bytes": 76,
"gzipBytes": 60,
"sha256": "653f3e53e89b4f8548ff86c19e92bb3c6b84b6be7485a320b1e00893ed877479"
}
]
},
Expand Down Expand Up @@ -291,19 +347,19 @@
"sha256": "8bbb58cbfd4633c1d828c6bc37bc32abc768a149c01b1dfc4f5131fd81274551"
},
{
"path": "NdnK0xH5LMQwOKWJEoBdU/_buildManifest.js",
"path": "EZN1vWdZRBGVR-raIzoF5/_buildManifest.js",
"bytes": 219,
"gzipBytes": 154,
"sha256": "9476e0afd3f9da68a64e6ec58631191fd3eaca62c9f5a377e7517fe9a89e59ea"
},
{
"path": "NdnK0xH5LMQwOKWJEoBdU/_clientMiddlewareManifest.js",
"path": "EZN1vWdZRBGVR-raIzoF5/_clientMiddlewareManifest.js",
"bytes": 96,
"gzipBytes": 66,
"sha256": "5483e3295dc35dfe29ea932efc85341c916f10ff78ff5b0e965e003710893a4a"
},
{
"path": "NdnK0xH5LMQwOKWJEoBdU/_ssgManifest.js",
"path": "EZN1vWdZRBGVR-raIzoF5/_ssgManifest.js",
"bytes": 80,
"gzipBytes": 64,
"sha256": "678f6ce2cb80b1fe72fc67e7412be6e2ab6ada083111b64f7c40d35e3cba5e00"
Expand Down Expand Up @@ -364,19 +420,19 @@
"sha256": "8bbb58cbfd4633c1d828c6bc37bc32abc768a149c01b1dfc4f5131fd81274551"
},
{
"path": "I5aaZFtg_xGKQHYGMZiI5/_buildManifest.js",
"path": "ihKICl1cD6PpEayME0Kbi/_buildManifest.js",
"bytes": 219,
"gzipBytes": 154,
"sha256": "9476e0afd3f9da68a64e6ec58631191fd3eaca62c9f5a377e7517fe9a89e59ea"
},
{
"path": "I5aaZFtg_xGKQHYGMZiI5/_clientMiddlewareManifest.js",
"path": "ihKICl1cD6PpEayME0Kbi/_clientMiddlewareManifest.js",
"bytes": 96,
"gzipBytes": 66,
"sha256": "5483e3295dc35dfe29ea932efc85341c916f10ff78ff5b0e965e003710893a4a"
},
{
"path": "I5aaZFtg_xGKQHYGMZiI5/_ssgManifest.js",
"path": "ihKICl1cD6PpEayME0Kbi/_ssgManifest.js",
"bytes": 80,
"gzipBytes": 64,
"sha256": "678f6ce2cb80b1fe72fc67e7412be6e2ab6ada083111b64f7c40d35e3cba5e00"
Expand Down
6 changes: 5 additions & 1 deletion launch/benchmarks/redact-frameworks/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ if (!framework) {
const children = []
let nextPort = 4380
for (const name of ['start', 'vite', 'vinext', 'next']) {
for (const runtime of ['react', 'redact']) {
for (const runtime of [
'react',
'redact',
...(['start', 'vite'].includes(name) ? ['redact-lean'] : []),
]) {
const serverPort = nextPort++
children.push(
spawn(
Expand Down
7 changes: 7 additions & 0 deletions launch/benchmarks/redact-frameworks/server-checks.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
"passed": true,
"checks": ["server-rendered greeting", "counter", "controlled input"]
},
{
"framework": "Start",
"renderer": "redact-lean",
"status": 200,
"passed": true,
"checks": ["server-rendered greeting", "counter", "controlled input"]
},
{
"framework": "Vinext",
"renderer": "react",
Expand Down
Loading
Loading