Next.js API Route Performance Overhead
Summary
- Goal: measure Vovk.ts overhead over native Next.js route handlers (not the HTTP stack).
- Routing: ~2.1 µs median per request from 1 to 1,000 controllers, 3.2 µs at 10,000 (20,000 endpoints) and 2.1 µs at 50,000 (100,000 endpoints). Throughput ~470k–490k ops/s per core up to 1,000 controllers. The GET path is a static route and the POST path one the route cache has seen; other paths are measured below the results.
- Cold start: ~11 µs per controller (two endpoints each) up to 1,000 controllers, ~14 µs at 10,000 and 50,000. ~11 ms at 1,000 controllers, ~137 ms at 10,000, ~0.71 s at 50,000. About 11–16× the cost of no-op decorators. The first request of each method then builds that method’s route table: ~0.11 s at 50,000 controllers.
- Notes: Tinybench on Node.js 24.21 and an Apple M4 Pro, measured on 2026-10-17. Each figure is the lowest median of five runs, since other work on the machine only adds time; the first-request table shows the median of the five runs. On Node.js 24.1 the same code routes a request in 1.58 µs; Node.js 24.21 adds about 0.5 µs. 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 61,111 test controllers. The suite takes about three minutes on an M4 Pro.
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, 50,000}), each with:
- GET without params.
- POST with path param “{id}” (pattern match).
- Two endpoints per controller, so 50,000 controllers serve 100,000 endpoints from one segment.
- 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 | 2,083 ns | 2,084 ns | 480,077 | 479,846 |
| 10 | 20 | 2,041 ns | 2,084 ns | 489,956 | 479,846 |
| 100 | 200 | 2,083 ns | 2,125 ns | 480,077 | 470,588 |
| 1,000 | 2,000 | 2,083 ns | 2,125 ns | 480,077 | 470,588 |
| 10,000 | 20,000 | 3,250 ns | 3,209 ns | 307,692 | 311,624 |
| 50,000 | 100,000 | 2,125 ns | 2,167 ns | 470,588 | 461,467 |
Key takeaways:
- Flat latency, ≈2.1 µs, up to 1,000 controllers; GET ≈ POST, so reading the path param costs little.
- At 10,000 controllers every run is slower, 3.2–3.75 µs; at 50,000 a request takes 2.1–2.3 µs, as at 1,000. A CPU profile puts the extra time at 10,000 in Node.js’s
Request/Responsecode; the Vovk.ts code takes about the same time at every size, about 16% more at 50,000 controllers than at 1,000. - The GET path is a static route, found by its literal key. The POST repeats one path with a param, which the route cache serves. Other paths are measured below.
Paths the route cache can’t serve
The route cache keeps up to 1,000 paths per segment and method. The first request to a new path with a param checks every route of its method. A path that matches no route is never cached: it checks the routes of every method, to answer 405 with an Allow header or 404. The test sends a new param value on each POST, and a GET to a path with no route (routeScan.ts).
| Controllers | Endpoints | New path with a param (med) | Path with no route (med) |
|---|---|---|---|
| 10 | 20 | 3.1 µs | 3.7 µs |
| 1,000 | 2,000 | 31.1 µs | 94.6 µs |
| 10,000 | 20,000 | 558 µs | 2.1 ms |
| 50,000 | 100,000 | 10.5 ms | 26.3 ms |
The cost depends on the routes in one segment, so segments keep it small.
First request
A method’s route table is built on its first request, and each route is parsed the first time a path is checked against it: a static path checks only its own route, while a new path with a param, or a path with no route, checks every route of the methods it scans. The test starts a fresh process three times per size and times the first GET (a static path), the first POST (a path with a param), and then the first path with no route, which builds the tables of the other methods (firstRequest.ts). A first request is one sample, and a garbage collection pause during it is part of its cost, so the table shows the median of the five runs, each the median of three processes.
| Controllers | Endpoints | First GET (med) | First POST (med) | First path with no route (med) |
|---|---|---|---|---|
| 1 | 2 | 7.9 ms | 0.24 ms | 0.13 ms |
| 1,000 | 2,000 | 8.8 ms | 1.85 ms | 1.5 ms |
| 10,000 | 20,000 | 22.2 ms | 22.5 ms | 21.2 ms |
| 50,000 | 100,000 | 111 ms | 115 ms | 152 ms |
At 1 controller the first GET takes 7.9 ms and the first POST 0.24 ms: about 8 ms of the first GET is a fresh process’s first request, at any size, not the route table.
Cold Start Overhead
Cold-start benchmark: N controllers of the request tests, defined in a loop and passed to initSegment(), against the same classes with no-op decorators:
function createSegment(count: number) {
const controllers: Record<string, Function> = {};
for (let i = 0; i < count; i++) {
@prefix(`c/${i}`)
class Controller {
@operation({ summary: "Get" })
@get()
static getOne = procedure().handle(() => null);
@operation({ summary: "Create" })
@post("{id}")
static create = procedure({
disableServerSideValidation: ["params"],
params,
}).handle(() => null);
}
controllers[`C${i}Controller`] = Controller;
}
return initSegment({ segmentName: "", emitSchema: true, controllers });
}
function createNoopClasses(count: number) {
const classes: Record<string, Function> = {};
for (let i = 0; i < count; i++) {
@noopClassDecorator()
class Controller {
@noopDecorator({ summary: "Get" })
@noopDecorator()
static getOne = () => null;
@noopDecorator({ summary: "Create" })
@noopDecorator("{id}")
static create = () => null;
}
classes[`C${i}Controller`] = Controller;
}
return classes;
}Methodology (short)
For N ∈ {1, 10, 100, 1,000, 10,000, 50,000} measure:
- Decorator processing, metadata build and
initSegment(). The route tables are built later, on each method’s first request. - Compare with equivalent classes using no-op decorators, to isolate the framework’s work.
- The classes are defined in a loop: at 50,000 classes, one function with every class written out makes V8 spend most of the run compiling that function.
Example no‑op decorators:
function noopDecorator(_arg?: unknown) {
return (_target: object, _key: string) => {};
}
function noopClassDecorator(_message?: string) {
return <T>(target: T) => target;
}Results
| Controllers | Vovk.ts Init Time (med) | No-op Time (med) | Overhead Ratio | Throughput (ops/s) |
|---|---|---|---|---|
| 1 | 11.833 μs | 0.750 μs | 15.8x | 84,509 |
| 10 | 106.542 μs | 7.167 μs | 14.9x | 9,386 |
| 100 | 1,037.666 μs | 76.417 μs | 13.6x | 964 |
| 1,000 | 10,995.937 μs | 779.917 μs | 14.1x | 91 |
| 10,000 | 137,407.458 μs | 10,123.854 μs | 13.6x | 7 |
| 50,000 | 710,273.646 μs | 66,843.376 μs | 10.6x | 1 |
Key takeaways:
- About 11 µs per controller (two endpoints each) up to 1,000 controllers, about 14 µs per controller at 10,000 and 50,000.
- 50,000 controllers (100,000 endpoints) take about 0.71 s, plus about 0.11 s for each method’s first request.
- 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.
- One segment with 100,000 endpoints (50,000 controllers) is measured above: ~2.1–2.3 µs per request to a static or cached path, ~0.71 s of cold start, ~0.11 s for each method’s first request, and then ~10.5 ms for a new path with a param and ~26 ms for a path with no route. Test your own app: the real limits are memory, bundle size, cold start budgets and platform quotas.
Benchmarks: Tinybench on Node.js 24.21, on an Apple M4 Pro, 2026-10-17; each figure is the lowest median of five runs. Numbers can vary with the runtime, hardware and build settings. Scripts: https://github.com/finom/vovk/tree/main/perf