Appearance
Benchmarks
2.0×
faster cold start than NestJS
150 ms vs 302 ms to the first response
26%
less memory than NestJS
89 MiB vs 122 MiB resident at idle
3.4×
faster DI container than NestJS
11 ms vs 36 ms to create 1,000 providers
Medians on an Apple M5 with Node.js 24.21.0 · HTTP applications on Fastify
Znight solves the dependency graph when you build, not when you boot. These benchmarks measure what that changes at runtime, against NestJS, the closest framework, against the runtime containers tsyringe and InversifyJS, and against the same application wired by hand on each HTTP server. Every number on this page comes from benchmarks/results/results.json, produced by the scripts of benchmarks/: run them on your machine to check them.
Summary
- 2.0× faster cold start than NestJS: 150 ms vs 302 ms to the first response.
- 26% less memory than NestJS: 89 MiB vs 122 MiB resident at idle.
- 3.4× faster DI container than NestJS: 11 ms vs 36 ms to create 1,000 providers.
- Throughput: Znight is level with NestJS on Node.js with Fastify: 125,344 requests per second for
GET /users/:id, against 124,646 for NestJS (0.6% more) and 142,807 for hand-wired Fastify (see Throughput).
Znight starts up to twice as fast as the same NestJS application and uses less memory, because there is less to load and less to do at boot. Under load, it is level with NestJS on Fastify and ahead of it on Express, on Node.js and on Bun. Its request pipeline does more per request than the hand-wired servers, which matters for services that answer in microseconds and much less for those that wait for a database.
What is measured
The application
Every framework serves the same small API, written the way its documentation recommends: a controller, a service and a repository (an in-memory map of 1,000 users), two routes, JSON.
| Route | What happens |
|---|---|
GET /users/:id | The id is parsed as an integer, the user read through the service and sent as JSON |
POST /users | The JSON body is validated with the same Zod 4 schema, a user created, 201 and JSON sent |
| Application | Code | Built with |
|---|---|---|
| Znight | apps/znight: @Controller, @Injectable, @Body(CreateUser) | znight build (Vite, decorators stripped) |
| NestJS 12 | apps/nestjs: two modules, ParseIntPipe, the built-in Standard Schema pipe | tsc with emitDecoratorMetadata, like nest build |
| Hand-wired | apps/baseline: the same classes created with new, routes registered by hand | tsc |
The hand-wired servers are the floor: the cost of the HTTP server itself, with no framework on top. Each framework runs with its defaults, which do not do the same work per request: Znight adds a request id and four security headers to every response, Express adds an ETag and x-powered-by. NestJS runs with logger: false, so that it does not log while the others stay silent. Every application runs the same build on Node.js and on Bun, with NODE_ENV=production.
Environment
| Setup | Value |
|---|---|
| CPU | Apple M5, 10 cores |
| Memory | 16 GiB |
| Operating system | macOS 26.6.2 |
| Runtimes | Node.js 24.21.0, Bun 1.4.4 |
| Measured | 2026-10-30, 2026-10-31, commit e1e4e4c |
| Package | Version |
|---|---|
@znight/core | 0.1.2 |
@znight/http | 0.1.2 |
@znight/compiler | 0.1.2 |
@nestjs/core | 12.3.2 |
@nestjs/platform-fastify | 12.3.2 |
@nestjs/platform-express | 12.3.2 |
fastify | 5.12.7 |
express | 5.3.0 |
hono | 4.13.14 |
@hono/node-server | 2.1.5 |
zod | 4.6.7 |
tsyringe | 4.10.0 |
inversify | 8.2.3 |
reflect-metadata | 0.2.2 |
autocannon | 8.0.0 |
typescript | 7.0.2 |
vite | 8.3.5 |
Cold start and memory
How long it takes from starting the process to answering a request: what a deployment, an autoscaler or a serverless platform waits for. The runner spawns node dist/main.js (or bun dist/main.js), requests GET /users/1 every millisecond until it gets a 200, and records the elapsed time: the runtime start, the imports, the application bootstrap and the first request. It then samples the resident memory of the idle process. Each application is started once before the measured runs (so the file system cache is warm for all), then the applications take turns, in a different order on each run.
| Application | Node.js: start | Node.js: memory | Bun: start | Bun: memory |
|---|---|---|---|---|
| Hand-wired (Fastify) | 132 ms 125–140 | 83 MiB 83–84 | 114 ms 103–123 | 48 MiB 48–49 |
| Znight (Fastify) | 150 ms 142–160 | 89 MiB 89–89 | 113 ms 108–123 | 54 MiB 54–54 |
| NestJS (Fastify) | 302 ms 291–320 | 122 MiB 111–122 | 243 ms 226–295 | 80 MiB 79–81 |
| Hand-wired (Express) | 112 ms 108–121 | 75 MiB 74–76 | 88 ms 83–90 | 48 MiB 47–48 |
| Znight (Express) | 138 ms 132–143 | 89 MiB 88–89 | 108 ms 97–121 | 50 MiB 49–50 |
| NestJS (Express) | 251 ms 241–265 | 110 MiB 110–110 | 166 ms 157–170 | 68 MiB 68–69 |
| Hand-wired (node:http) | 88 ms 83–90 | 75 MiB 75–75 | 45 ms 41–48 | 34 MiB 34–34 |
| Znight (node:http) | 99 ms 95–106 | 79 MiB 78–80 | 61 ms 52–65 | 39 MiB 39–40 |
| Hand-wired (Hono) | 96 ms 89–101 | 80 MiB 80–80 | 34 ms 30–38 | 32 MiB 31–32 |
| Znight (Hono) | 107 ms 103–120 | 81 MiB 81–82 | 62 ms 58–68 | 40 MiB 40–40 |
Median of 30 starts, with the interquartile range (25th to 75th percentile) in small type. Memory is sampled 500 ms after the first response.
On both runtimes and with both adapters, a Znight application is ready in half to two thirds of the time of the same NestJS application, and uses less memory. A NestJS application loads reflect-metadata, discovers its modules, reads the decorator metadata of every class and resolves the graph on every boot; a Znight application imports a generated module and calls its factories in an order computed at build time. What separates Znight from the hand-wired servers, at most a few tens of milliseconds, is loading @znight/core and @znight/http, building the route table and creating the providers.
Throughput and latency
Requests per second under load: autocannon, run by Node.js in its own process, keeps 100 connections busy on one route. For each round, every application is started, warmed up on the route, measured, then stopped; the rounds change the order of the applications.
GET /users/:id
| Application | Node.js: requests/s | Node.js: p99 | Bun: requests/s | Bun: p99 |
|---|---|---|---|---|
| Hand-wired (Fastify) | 142,807 142,028–144,634 | 1 ms | 158,435 157,376–159,098 | 1 ms |
| Znight (Fastify) | 125,344 120,294–126,432 | 1 ms | 138,164 127,334–138,781 | 1 ms |
| NestJS (Fastify) | 124,646 121,975–124,948 | 1 ms | 132,817 131,468–133,088 | 1 ms |
| Hand-wired (Express) | 89,172 78,275–89,213 | 2 ms | 130,176 129,091–130,327 | 1 ms |
| Znight (Express) | 108,064 107,529–109,507 | 1 ms | 128,736 122,319–129,865 | 1 ms |
| NestJS (Express) | 80,945 79,671–82,627 | 2 ms | 117,670 117,396–118,007 | 1 ms |
| Hand-wired (node:http) | 145,100 144,762–146,787 | 1 ms | 168,396 167,779–168,721 | 1 ms |
| Znight (node:http) | 127,625 125,804–128,294 | 1 ms | 138,537 137,245–140,259 | 1 ms |
| Hand-wired (Hono) | 131,183 127,540–132,407 | 1 ms | 174,307 160,381–174,854 | 1 ms |
| Znight (Hono) | 116,460 111,840–118,577 | 1 ms | 147,031 140,620–147,718 | 1 ms |
POST /users (validated body)
| Application | Node.js: requests/s | Node.js: p99 | Bun: requests/s | Bun: p99 |
|---|---|---|---|---|
| Hand-wired (Fastify) | 118,420 114,929–118,659 | 1 ms | 127,985 126,886–130,057 | 1 ms |
| Znight (Fastify) | 107,174 107,110–108,029 | 1 ms | 117,065 107,488–117,065 | 1 ms |
| NestJS (Fastify) | 99,703 99,028–99,732 | 1 ms | 107,116 106,767–107,853 | 1 ms |
| Hand-wired (Express) | 74,627 65,873–74,947 | 2 ms | 101,652 97,975–102,415 | 1 ms |
| Znight (Express) | 89,242 86,874–89,585 | 2 ms | 110,566 110,124–110,787 | 1 ms |
| NestJS (Express) | 64,887 62,772–66,406 | 2 ms | 88,323 88,259–89,975 | 2 ms |
| Hand-wired (node:http) | 126,019 125,734–126,077 | 1 ms | 143,040 142,592–143,366 | 1 ms |
| Znight (node:http) | 108,751 104,428–109,763 | 1 ms | 117,676 117,268–118,391 | 1 ms |
| Hand-wired (Hono) | 105,766 104,457–107,529 | 1 ms | 161,844 160,250–162,240 | 1 ms |
| Znight (Hono) | 95,828 93,455–99,674 | 1 ms | 123,244 117,018–124,111 | 1 ms |
Median of 3 rounds of 10 seconds (minimum–maximum in small type), each after a 3-second warm-up, 100 connections, no pipelining. Latency: the 99th percentile reported by autocannon, which records whole milliseconds.
Under load, Znight serves at least as many requests per second as NestJS on the same server. On Fastify it is 1% to 9% ahead, which is within this machine's noise (see Caveats); on Express it is 9% to 38% ahead, and level with or ahead of the hand-wired Express application, whose res.json() it does not use. It stays 9% to 24% below the hand-wired Fastify, node:http and Hono servers. The servers are the same, so that difference is Znight's request pipeline: for every request it opens a request scope, generates a request id, writes its security headers, and serializes the response itself before handing it to the server. This overhead matters for services that answer from memory; for a service whose requests wait for a database or another service, it is rarely what limits throughput.
Dependency injection
The container alone, without HTTP. A generated project of N provider classes, one module each, in 10 layers: each provider takes three providers of the layer below in its constructor. Each framework gets the same classes with its own decorator, and an entry that imports them, creates the container and instantiates every provider (NestJS: createApplicationContext() of a module listing them all; tsyringe and InversifyJS: one singleton registration per class, then one resolution per class). All projects, Znight's included, are compiled by tsc to one module per class, so that loading the modules costs the same everywhere.
Container creation: the container is created and every provider instantiated, measured inside the process once every module is imported.
| Container | Node.js: 100 | Node.js: 1,000 | Bun: 100 | Bun: 1,000 |
|---|---|---|---|---|
| Znight | 1.8 ms 1.7–2.0 | 11 ms 9.8–13 | 2.2 ms 2.0–3.1 | 11 ms 10–14 |
| tsyringe | 1.0 ms 0.9–1.1 | 5.9 ms 5.5–6.4 | 1.1 ms 1.1–1.3 | 6.7 ms 6.0–8.9 |
| InversifyJS | 3.4 ms 3.1–3.9 | 15 ms 14–18 | 3.9 ms 3.2–5.1 | 16 ms 14–20 |
| NestJS | 7.0 ms 6.6–8.1 | 36 ms 34–43 | 11 ms 9.9–14 | 36 ms 34–52 |
Whole process: spawned to exit, including the runtime start and the imports of the framework and of every provider module.
| Container | Node.js: 100 | Node.js: 1,000 | Bun: 100 | Bun: 1,000 |
|---|---|---|---|---|
| Znight | 45 ms 42–55 | 171 ms 163–249 | 39 ms 35–41 | 82 ms 77–91 |
| tsyringe | 58 ms 52–72 | 230 ms 223–277 | 23 ms 22–29 | 104 ms 94–119 |
| InversifyJS | 83 ms 78–107 | 259 ms 240–316 | 46 ms 42–52 | 163 ms 158–175 |
| NestJS | 157 ms 142–192 | 335 ms 323–456 | 107 ms 104–133 | 222 ms 210–263 |
Median of 30 processes, with the interquartile range in small type.
Build-time cost: what Znight moves to the build. The compiler (generate(), the work of znight generate and of the Vite plugin) scans, parses, analyses, binds and validates the providers and writes the container module, in a fresh Node.js process:
| Providers | generate() on Node.js |
|---|---|
| 100 | 82 ms 73–96 |
| 1,000 | 637 ms 548–664 |
Znight's container is created three to five times faster than NestJS's, and faster than InversifyJS's. tsyringe creates its providers faster still: it only resolves constructors, while Znight's container also checks the generated metadata, records every instance for the shutdown and applies method interceptors, and each generated factory runs once, compiled on its first call. Over the whole process, which is what a boot costs, Znight is the fastest on Node.js at both sizes, and on Bun with 1,000 providers (tsyringe is faster with 100): it loads neither reflect-metadata nor decorator metadata, and its decorators do nothing at runtime. The analysis moves to the build, where generating the container takes a fraction of a second, once per build (incrementally in watch mode).
Caveats
- A laptop, not a lab. The machine also ran a desktop session. Rounds are interleaved so that noise spreads over every application, and the spread is published, but absolute numbers will differ on your hardware: compare the applications with each other, on the same machine.
- Load generator on the same machine. autocannon shares the CPU with the server. The fastest hand-wired servers may be limited by it; the framework applications, slower, are not. A dedicated client machine would give higher numbers for everyone. autocannon records latencies in whole milliseconds, so this page shows their 99th percentile only.
- Noise. Differences of about 10% or less, or within the ranges shown, are not significant on this machine.
- Small, synthetic workloads. The routes do no I/O: they measure the framework, not your database. In most services, the time spent in the database, the network and your own code is larger than any difference shown here.
- Defaults, not tuning. No framework is tuned beyond turning NestJS's logger off. In particular, Znight's security headers and request ids are on, as they are in your application by default.
- Dependency injection projects are compiled with
tsc. A Znight application is normally built by Vite, which bundles the provider modules and strips the decorators: the dependency injection suite does not use that advantage.
Reproduce
The benchmarks are a workspace of the repository, benchmarks/. They are not run by the continuous integration: run them on your machine (about 40 minutes for everything).
sh
git clone https://github.com/znight-org/znight.git
cd znight
bun install
bun run build # the @znight/* packages, used by the Znight application
cd benchmarks
bun run bench # builds the applications, then every suite
bun run report # the charts and the tables of this page, from the resultsbun run bench:boot, bench:http and bench:di run one suite; bun src/cli.ts http --runtimes node --variants znight-fastify,nestjs-fastify restricts a run; --quick checks the setup in a few minutes (its results go to results/quick.json). The benchmarks README describes the layout and how to add a framework.