Next.js API Route Performance Overhead
Summary
- Goal: measure Vovk.ts overhead over native Next.js route handlers (not the HTTP stack).
- Routing: ~1.5 µs median per request from 1 to 1,000 controllers, ~1.75–1.83 µs at 10,000 (20,000 endpoints). Throughput ~545k–675k ops/s per core. The test repeats one path, which the route cache serves; see the notes below the results.
- Cold start: grows linearly. ~8 ms at 1,000 controllers, ~114 ms at 10,000. About 11–12× the cost of no-op decorators.
- Notes: Tinybench on Node.js 24.1 and an Apple M4 Pro, measured on 2026-10-06. Next.js runtime cost is not measured. Written from real benchmark output with AI help and small edits. The scripts are in the
perffolder of the Vovk.ts repository.
Reproducing the Tests
Clone the repository, install dependencies at its root, and build the packages:
git clone https://github.com/finom/vovk.git
cd vovk
npm ci
npm run buildRun the performance tests from the perf folder. The script first generates the 11,111 test controllers.
cd perf
npm run perf-testThe suite also runs in CI: GitHub Actions . CI uses GitHub-hosted runners, which are slower than a local M4 Pro, so its numbers are higher; the scaling is the same.
Overview
Vovk.ts runs on top of Next.js API routes and builds the handlers from decorated procedures:
export const { GET, POST } = initSegment({ controllers });The tests measure the overhead in two ways:
- Request overhead: routing and handler time per request.
- Cold start overhead: time to initialize controllers and metadata.
Request Overhead
Example controller (N = 1) used in the request-overhead tests:
import { procedure, prefix, get, post, operation } from "vovk";
import z from "zod";
@prefix("a")
export default class AController {
@operation({
summary: "Get A",
})
@get()
static getA = procedure().handle(() => {
return { get: true };
});
@operation({
summary: "Create A",
})
@post("{id}")
static createA = procedure({
disableServerSideValidation: ["params"],
params: z.object({ id: z.string() }),
}).handle((_req, { id }) => {
return { post: true, id };
});
}Every test controller is generated by scripts/createModules.ts.
Methodology (short)
- Generate N controllers (N ∈ {1, 10, 100, 1,000, 10,000}), each with:
- GET without params.
- POST with path param “{id}” (pattern match).
- Minimal handlers; the full routing and handler path is measured.
- Tinybench: at least 100 ms per test, nanosecond timing; median latency and throughput.
Results
| Controllers | Endpoints | GET Latency (med) | POST Latency (med) | GET Throughput (med ops/s) | POST Throughput (med ops/s) |
|---|---|---|---|---|---|
| 1 | 2 | 1,500 ns | 1,500 ns | 666,667 | 666,667 |
| 10 | 20 | 1,500 ns | 1,480 ns | 666,667 | 676,034 |
| 100 | 200 | 1,500 ns | 1,500 ns | 666,667 | 666,667 |
| 1,000 | 2,000 | 1,500 ns | 1,500 ns | 666,667 | 666,667 |
| 10,000 | 20,000 | 1,833 ns | 1,750 ns | 545,554 | 571,429 |
Key takeaways:
- Flat latency up to 1,000 controllers, about 20% higher at 10,000.
- ≈1.5 µs overhead at typical sizes; GET ≈ POST, so reading the path param costs little.
- The test repeats one path, which the route cache serves. The first request to a new path with a param scans the routes of its method: about 2.7 µs at 10 controllers, 42 µs at 1,000 and 0.8 ms at 10,000. A path that matches no route is never cached and costs about 3.4 µs, 123 µs and 3.1 ms.
Cold Start Overhead
Example cold-start benchmark (N = 1), Vovk.ts against no-op decorators:
bench.add("Cold start for 1 controllers", async () => {
const controllers: Record<string, Function> = {};
@prefix("one/0")
class One0Controller {
@operation({
summary: "Create",
})
@post("{id}")
static create = procedure().handle(() => null);
}
controllers["One0Controller"] = One0Controller;
initSegment({
segmentName: "",
emitSchema: true,
controllers,
});
});
bench.add("No-op decorators for 1 classes", async () => {
const controllers: Record<string, Function> = {};
@noopClassDecorator()
class One0Controller {
@noopDecorator({
summary: "Create",
})
@noopDecorator("{id}")
static create = () => {
return null;
};
}
});Methodology (short)
For N ∈ {1, 10, 100, 1,000, 10,000} measure:
- App creation, decorator processing, metadata build and
initSegment(). - Compare with equivalent classes using no-op decorators, to isolate the framework’s work.
Example no‑op decorators:
function noopDecorator() {
return function (..._args: any[]) {};
}
function noopClassDecorator() {
return function <T extends new (...a: any[]) => any>(c: T) {
return c;
};
}Results
| Controllers | Vovk.ts Init Time (med) | No-op Time (med) | Overhead Ratio | Throughput (ops/s) |
|---|---|---|---|---|
| 1 | 6.896 μs | 0.583 μs | 11.8x | 145,023 |
| 10 | 64.750 μs | 5.229 μs | 12.4x | 15,444 |
| 100 | 664.021 μs | 56.251 μs | 11.8x | 1,507 |
| 1,000 | 8,000.167 μs | 698.417 μs | 11.5x | 125 |
| 10,000 | 113,550.011 μs | 18,652.260 μs | 6.1x | 9 |
Key takeaways:
- Linear growth: about 6.5–8 µs per controller up to 1,000 controllers, about 11 µs per controller at 10,000.
- At 10,000 classes, garbage collection dominates the no-op run, so its ratio is low.
- Absolute times are small for long-running services, and acceptable for serverless at typical sizes.
Practical guidance
- For high-performance workloads, split the app into several segments (serverless functions built from Next.js route.ts files), each with up to 1,000 procedures.
- In theory, with well-planned segments and enough hardware, one Next.js/Vovk.ts app can host up to ~100,000 procedures. Test this in your environment: the real limits are memory, bundle size, cold start budgets and platform quotas.
Benchmarks: Tinybench on Node.js 24.1, on an Apple M4 Pro, 2026-10-06. Numbers can vary with the runtime, hardware and build settings. Scripts: https://github.com/finom/vovk/tree/main/perf