A standalone benchmarking suite that answers two questions:
- What overhead does Node-Boot add on top of the raw HTTP framework it wraps (Express,
Fastify, Koa, native
http)? - How does the same Node-Boot application perform across different deployments — long-lived HTTP server vs. serverless/edge platforms?
This folder is a member of the root node-boot pnpm + Turborepo monorepo (see the root
pnpm-workspace.yaml) and theapps/nodeboot-*apps depend on@nodeboot/*packages via theworkspace:*protocol. That means every benchmark run always builds against the current, uncommittedpackages/*/servers/*source — so this suite doubles as a pre-release regression gate: run it before publishing a@nodeboot/core/driver change to check whether throughput regressed vs. the last recorded run inresults/history/. See Extracting this into its own repo for how to go back to published-version-only dependencies if this ever needs to be split out standalone.
See results/ for the most recent benchmark run:
results/README.md— chart-enabled report that renders directly in the GitHub UI (recommended starting point).results/REPORT.md— the same data as plain markdown tables, no charts.results/REPORT.html— a self-contained, fully offline HTML report (download/open locally for the interactive version).results/history/— every past run, archived by Node-Boot version and timestamp, so results can be diffed across releases.
The existing samples/sample-express, sample-fastify, sample-koa, sample-native-http, etc.
are great feature demos, but they aren't comparable to each other for load testing: they each
enable a different mix of starters (persistence, OpenAPI, actuator, validation, authorization...),
so any throughput difference between them would be dominated by which starters are enabled, not
by which HTTP framework/adapter is used. To isolate the two questions above, this suite instead
uses purpose-built minimal apps that mirror samples/* conventions exactly, but with a fixed,
matching feature set across the whole family:
apps/raw-*— the bare framework (Express/Fastify/Koa/nativehttp), no Node-Boot at all, structured the way that framework's own community/docs recommend for a small REST API (see Raw apps below).apps/nodeboot-*— the same routes/behaviour, built by following the real Node-Boot conventions from thenodeboot-coreandnodeboot-starter-persistence-sqlskills (mirroringsamples/sample-express's persistence layer):@Controller/@Get/@Postdecorators, the matching@nodeboot/*-serveradapter,@EnableDI+@EnableRepositories(), a real@DataRepository+ TypeORM entity, andapp-config.yaml-driven configuration — not a hand-rolled shortcut.
No code is shared between apps. Each app under apps/ is a fully independent, self-contained
package with its own copy of the entity/repository/service/controller/routing code. This is
intentional: it keeps each app representative of what a real project in that ecosystem looks like,
and avoids a shared abstraction layer quietly absorbing (or hiding) framework-specific overhead.
Every app exposes the same four routes, backed by a real PostgreSQL database via TypeORM (chosen
over SQLite because better-sqlite3 is a synchronous, event-loop-blocking driver — under
concurrent load its write-lock contention dominates measured latency/throughput, drowning out the
very Node-Boot-vs-raw differences this suite exists to measure). Every app connects to the same
local Postgres instance (started via docker compose, see below) but each owns its own database,
so apps never share or leak state between runs:
| Route | Method | Behaviour |
|---|---|---|
/hello |
GET | No DB access — pure framework/routing overhead |
/todos |
GET | List (SELECT ... LIMIT 20, ORDER BY id DESC) from a 1,000-row todo table |
/todos/:id |
GET | Single-row SELECT by primary key |
/todos |
POST | Single-row INSERT |
Each apps/raw-* app uses TypeORM directly (same ORM/driver Node-Boot uses under the hood) so
that the raw-vs-nodeboot comparison isolates Node-Boot's own routing/DI/lifecycle overhead, rather
than mixing in a different ORM's overhead. Structure follows each ecosystem's own conventions:
| App | Pattern |
|---|---|
raw-express |
express.Router() per resource, a data-source.ts TypeORM module, centralized 4-arg error-handling middleware (TypeORM's own Express quick-start pattern) |
raw-fastify |
fastify-plugin-wrapped ORM decorator plugin (fastify.orm), one route plugin per resource, registered via fastify.register(...) |
raw-koa |
@koa/router per resource with its own prefix, koa-bodyparser, try/catch error-handling middleware registered first (Koa has no built-in error handler) |
raw-http |
Manual method + URL pathname matching in a single request handler — the standard zero-dependency Node.js pattern |
Each apps/nodeboot-* app follows the exact same shape as samples/sample-express's persistence
layer: a Todo TypeORM entity, a TodoRepository via @DataRepository(Todo), a TodoService
that seeds 1,000 rows on boot if the table is empty, and HelloController/TodoController classes
using @Controller/@Get/@Post. app.ts uses @EnableDI(Container) + @EnableRepositories()
plus explicit side-effect imports of the repository (no @nodeboot/aot/@EnableComponentScan(),
since these are small single-file-per-concern apps — see samples/sample-cloudflare for the same
explicit-import pattern). Only the persistence starter is enabled, keeping the comparison to
"Node-Boot core + persistence overhead", not muddied by other starters (OpenAPI, actuator, etc).
- Ghost server (no HTTP listener) and Encore (requires the Encore cloud runtime) are not part of the raw-throughput tier for that reason; they're better suited to the deployment tier below if/when needed.
benchmarking/
apps/raw-http/ Plain node:http + TypeORM baseline (own copy of entity/routes)
apps/raw-express/ Plain Express + TypeORM baseline (own copy of entity/routes)
apps/raw-fastify/ Plain Fastify + TypeORM baseline (own copy of entity/routes)
apps/raw-koa/ Plain Koa + TypeORM baseline (own copy of entity/routes)
apps/nodeboot-http/ Node-Boot on @nodeboot/http-server + @nodeboot/starter-persistence
apps/nodeboot-express/ Node-Boot on @nodeboot/express-server + @nodeboot/starter-persistence
apps/nodeboot-fastify/ Node-Boot on @nodeboot/fastify-server + @nodeboot/starter-persistence
apps/nodeboot-koa/ Node-Boot on @nodeboot/koa-server + @nodeboot/starter-persistence
docker-compose.yaml Single Postgres instance shared by every app (one DB per app)
docker/init-databases.sh Creates the 8 per-app databases on first container boot
tools/runner/ autocannon-based CLI (bench.mjs) -> results/<app>__<endpoint>.json
tools/report/ Aggregates results/*.json -> results/REPORT.md + results/REPORT.html,
archives every run into results/history/
scripts/run-all.sh Resets Postgres, builds + benchmarks every app serially, generates the report
results/ Latest run's JSON + REPORT.md/html (git-ignored, regenerated every run)
results/history/ Archived reports, one folder per run (tracked in git for comparison)
Every app listens on its own fixed port (no shared $PORT env var) and its own fixed
Postgres database (same instance, different database name), so any app can be started
standalone for manual testing without colliding with another:
| App | Port | Database |
|---|---|---|
raw-http |
4001 | raw_http |
raw-express |
4002 | raw_express |
raw-fastify |
4003 | raw_fastify |
raw-koa |
4004 | raw_koa |
nodeboot-http |
4011 | nodeboot_http |
nodeboot-express |
4012 | nodeboot_express |
nodeboot-fastify |
4013 | nodeboot_fastify |
nodeboot-koa |
4014 | nodeboot_koa |
Requires Node.js >= 18, pnpm >= 8, curl (used by the orchestrator to wait for readiness), and
Docker (for the shared benchmark Postgres instance — docker compose must be available; on macOS
this can be Colima + docker/docker-compose, or Docker
Desktop).
Install/build from the repo root (this is now a regular workspace member, not a separate install root):
pnpm install # from the node-boot repo root
pnpm run bench:all # root script: builds packages/servers deps + all benchmark apps, runs the suite
# or, equivalently, from this folder:
cd benchmarking && pnpm run bench:allpnpm run bench:all (scripts/run-all.sh) automatically runs docker compose down -v && docker compose up -d --wait before the first app starts, so every run begins from a completely fresh
Postgres instance (all 8 databases recreated empty), then truncates+reseeds each app's table right
before it starts — mirroring the old "delete the sqlite file" full-reset behaviour. If you'd rather
manage Postgres yourself, start it once with docker compose up -d from this folder and it'll be
reused (down -v in the script will still reset it on the next bench:all run).
Because apps/nodeboot-* depend on @nodeboot/* via workspace:*, pnpm run bench:all always
measures the current local source of packages/*/servers/* — including any uncommitted changes
— not the last published npm release.
Tune load with env vars: BENCH_DURATION (seconds, default 10), BENCH_CONNECTIONS (default 50).
Each app's port is fixed (see the table above), so there's no BENCH_PORT to configure.
To benchmark a single already-running app manually (make sure docker compose up -d --wait has
been run first):
cd apps/nodeboot-express && pnpm build && pnpm start # starts on its fixed port, e.g. 4012
node tools/runner/bench.mjs --url http://localhost:4012/hello --app nodeboot-express --endpoint hello
node tools/report/aggregate.mjsresults/REPORT.md/results/REPORT.html include, per endpoint, a req/sec + latency (p50/p99)
table for every app, plus a "Node-Boot overhead vs raw framework" delta table pairing
raw-<x> with nodeboot-<x>. REPORT.html additionally renders inline SVG bar charts (no
internet/CDN required) for a quick visual comparison.
Every time tools/report/aggregate.mjs runs (via pnpm run bench:all or standalone), it archives
the run's raw JSON + both reports into results/history/<nodeboot-version>__<timestamp>/, and adds
a row to results/history/index.md linking to it. The Node-Boot version is resolved from the
actual installed @nodeboot/core package (not just the semver range in package.json), so
re-running after a pnpm update of @nodeboot/* produces a distinct, labeled entry you can diff
against previous runs — e.g. to check whether a Node-Boot release regressed or improved throughput.
Unlike results/REPORT.* (regenerated — and git-ignored — on every run), results/history/ is
tracked in git so past runs stay available for comparison.
Comparing how Node-Boot behaves across deployments (long-lived server vs. Lambda vs. Cloudflare Workers vs. Vercel vs. Netlify vs. Google Cloud Functions) is a different kind of benchmark — it's dominated by cold starts, cloud region, and platform-specific cost/latency characteristics, not by code in this repo. Rather than duplicating each adapter here, reuse the existing production-grade samples, since they already implement every adapter correctly:
| Deployment | Sample to deploy as-is |
|---|---|
| Long-lived HTTP (Express/Fastify/Koa/native http) | samples/sample-express, sample-fastify, sample-koa, sample-native-http |
| AWS Lambda | samples/sample-lambda |
| Cloudflare Workers | samples/sample-cloudflare |
| Vercel | samples/sample-vercel |
| Netlify Functions | samples/sample-netlify |
| Google Cloud Functions | samples/sample-google-cloud-functions |
| Encore.ts | samples/sample-encore |
Workflow:
- Deploy each sample to its target platform following that sample's own README.
- Point
tools/runner/bench.mjsat the deployed URL instead oflocalhost:node tools/runner/bench.mjs --url https://<your-lambda-url>/api/hello --app deploy-lambda --endpoint hello
- Run
node tools/report/aggregate.mjsto fold the deployment results into the sameresults/REPORT.mdalongside the local HTTP-server tier (use distinct--appnames, e.g.deploy-lambda,deploy-cloudflare, so they don't collide with the local raw/nodeboot pairs). - For cold-start specifically (serverless platforms only), run a single request after a period of
inactivity and record wall-clock time separately —
autocannon's steady-state req/sec is not a meaningful cold-start metric.
This folder now lives in the root pnpm workspace and apps/nodeboot-* depend on @nodeboot/* via
workspace:*, which is what lets pnpm run bench:all measure local/uncommitted core changes
before a release. If this ever needs to become a standalone repo again, that trade-off has to be
reversed first:
- Rewrite every
workspace:*dependency inapps/nodeboot-*/package.jsonback to a published semver range (e.g."@nodeboot/core": "^1.16.3", matching the version you want to pin against). - Add back a scoped
pnpm-workspace.yaml(packages: [apps/*, tools/*]) andturbo.jsoninsidebenchmarking/, and remove thebenchmarking/benchmarking/apps/*/benchmarking/tools/*entries from the rootpnpm-workspace.yaml. - Split the folder out:
git subtree split --prefix=benchmarking -b nodeboot-benchmarking # push that branch to a new, empty repository
After step 1, pnpm install in the extracted repo will pull the pinned published versions from
the npm registry instead of linking local source.