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–2.3 µs median per request from 1 to 1,000 controllers, 3.2–3.7 µs at 10,000 (20,000 endpoints) and 2.2–2.3 µs at 50,000 (100,000 endpoints). Throughput ~440k–480k ops/s per core up to 1,000 controllers. A new path with a param or a path with no route takes 2.3–4.0 µs at every size.
- Cold start: ~11–12 µs per controller (two endpoints each) up to 1,000 controllers, ~14–16 µs at 10,000 and 50,000. ~12 ms at 1,000 controllers, ~143 ms at 10,000, ~0.78 s at 50,000. About 11–16× the cost of no-op decorators. The first request that needs a method’s route tree then builds it: ~0.14–0.15 s at 50,000 controllers.
- Notes: Tinybench on Node.js 24.21 and an Apple M4 Pro, measured on 2026-10-22. 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 2026-10-17, the router took 1.58 µs per request on Node.js 24.1 and about 0.5 µs more on Node.js 24.21. 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,209 ns | 480,077 | 452,694 |
| 10 | 20 | 2,083 ns | 2,250 ns | 480,077 | 444,444 |
| 100 | 200 | 2,083 ns | 2,250 ns | 480,077 | 444,444 |
| 1,000 | 2,000 | 2,083 ns | 2,250 ns | 480,077 | 444,444 |
| 10,000 | 20,000 | 3,209 ns | 3,708 ns | 311,624 | 269,687 |
| 50,000 | 100,000 | 2,166 ns | 2,333 ns | 461,681 | 428,633 |
Key takeaways:
- Flat latency, ≈2.1 µs for GET and ≈2.3 µs for POST, up to 1,000 controllers.
- At 10,000 controllers every run is slower, 3.2–3.8 µs; at 50,000 a request takes 2.2–2.4 µs, about as at 1,000. A CPU profile puts the extra time at 10,000 in Node.js’s
Request/Responsecode, not in the router. - The GET path is a static route, found by its literal key. The POST path has a param: it is matched through a tree of path segments, so the number of routes doesn’t change its cost. It takes about 0.1 µs more than in 4.1.3, which served a repeated path from a cache. Other paths are measured below.
New paths and paths with no route
Routes with params are matched one segment at a time, in a tree built per segment and method, so a new param value costs the same as a repeated one. A path that matches no route walks the trees 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 | 2.3 µs | 2.8 µs |
| 1,000 | 2,000 | 2.5 µs | 3.0 µs |
| 10,000 | 20,000 | 3.9 µs | 4.0 µs |
| 50,000 | 100,000 | 2.5 µs | 3.0 µs |
Before 4.1.4, the router checked every route of the method for such a path, and kept up to 1,000 matched paths in a cache: 10.5 ms for a new path with a param and 26 ms for a path with no route at 50,000 controllers.
First request
A static path is found by its literal key. A path with a param, or a path with no route, needs the route tree of each method it walks, and a method’s tree is built on the first request that needs it. 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 trees 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 | 8.8 ms | 0.29 ms | 0.15 ms |
| 1,000 | 2,000 | 9.2 ms | 2.1 ms | 1.3 ms |
| 10,000 | 20,000 | 24.1 ms | 27.1 ms | 20.3 ms |
| 50,000 | 100,000 | 126 ms | 152 ms | 143 ms |
At 1 controller the first GET takes 8.8 ms and the first POST 0.29 ms: about 8 ms of the first GET is a fresh process’s first request, at any size, not the route tree.
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 trees are built later, on the first request that needs each. - 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 | 12.084 μs | 0.750 μs | 16.1x | 82,754 |
| 10 | 103.625 μs | 7.291 μs | 14.2x | 9,650 |
| 100 | 1,062.167 μs | 77.250 μs | 13.7x | 941 |
| 1,000 | 11,534.959 μs | 816.875 μs | 14.1x | 87 |
| 10,000 | 142,731.500 μs | 11,989.208 μs | 11.9x | 7 |
| 50,000 | 779,390.895 μs | 68,822.479 μs | 11.3x | 1 |
Key takeaways:
- About 11–12 µs per controller (two endpoints each) up to 1,000 controllers, about 14–16 µs per controller at 10,000 and 50,000.
- 50,000 controllers (100,000 endpoints) take about 0.78 s, plus 0.14–0.15 s for the first request that builds a method’s route tree.
- 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.2–2.3 µs per request, 2.5–3 µs for a new path with a param or a path with no route, ~0.78 s of cold start and 0.14–0.15 s for the first request that builds a method’s route tree. 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-22; 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