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 µ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 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,084 ns480,077479,846
10202,041 ns2,084 ns489,956479,846
1002002,083 ns2,125 ns480,077470,588
1,0002,0002,083 ns2,125 ns480,077470,588
10,00020,0003,250 ns3,209 ns307,692311,624
50,000100,0002,125 ns2,167 ns470,588461,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/Response code; 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).

ControllersEndpointsNew path with a param (med)Path with no route (med)
10203.1 µs3.7 µs
1,0002,00031.1 µs94.6 µs
10,00020,000558 µs2.1 ms
50,000100,00010.5 ms26.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.

ControllersEndpointsFirst GET (med)First POST (med)First path with no route (med)
127.9 ms0.24 ms0.13 ms
1,0002,0008.8 ms1.85 ms1.5 ms
10,00020,00022.2 ms22.5 ms21.2 ms
50,000100,000111 ms115 ms152 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:

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

ControllersVovk.ts Init Time (med)No-op Time (med)Overhead RatioThroughput (ops/s)
111.833 μs0.750 μs15.8x84,509
10106.542 μs7.167 μs14.9x9,386
1001,037.666 μs76.417 μs13.6x964
1,00010,995.937 μs779.917 μs14.1x91
10,000137,407.458 μs10,123.854 μs13.6x7
50,000710,273.646 μs66,843.376 μs10.6x1

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 

Last updated on