Skip to Content
Route Performance Overhead

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 perf folder 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 build

Run 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-test

The 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:

src/app/api/[[...vovk]]/route.ts
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:

src/modules/one/a/a-controller.ts
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

ControllersEndpointsGET Latency (med)POST Latency (med)GET Throughput (med ops/s)POST Throughput (med ops/s)
122,083 ns2,209 ns480,077452,694
10202,083 ns2,250 ns480,077444,444
1002002,083 ns2,250 ns480,077444,444
1,0002,0002,083 ns2,250 ns480,077444,444
10,00020,0003,209 ns3,708 ns311,624269,687
50,000100,0002,166 ns2,333 ns461,681428,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/Response code, 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).

ControllersEndpointsNew path with a param (med)Path with no route (med)
10202.3 µs2.8 µs
1,0002,0002.5 µs3.0 µs
10,00020,0003.9 µs4.0 µs
50,000100,0002.5 µs3.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.

ControllersEndpointsFirst GET (med)First POST (med)First path with no route (med)
128.8 ms0.29 ms0.15 ms
1,0002,0009.2 ms2.1 ms1.3 ms
10,00020,00024.1 ms27.1 ms20.3 ms
50,000100,000126 ms152 ms143 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:

bench/coldStartPerfTest.ts
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

ControllersVovk.ts Init Time (med)No-op Time (med)Overhead RatioThroughput (ops/s)
112.084 μs0.750 μs16.1x82,754
10103.625 μs7.291 μs14.2x9,650
1001,062.167 μs77.250 μs13.7x941
1,00011,534.959 μs816.875 μs14.1x87
10,000142,731.500 μs11,989.208 μs11.9x7
50,000779,390.895 μs68,822.479 μs11.3x1

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 

Last updated on