Effect HttpApi cold start

effect npm 4.0.5 · node v24.21.0 · linux 6.18.54 · Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz (8 cores) · rolldown 1.2.14 · 30 fresh processes per size · Sat, 17 Oct 2026 09:53:24 GMT

Cold start = start the server and answer one request. Each sample launches a brand-new node process running a single rolldown bundle of an effect/http-api server (HttpRouter.serve on @effect/platform-node's NodeHttpServer), waits until it is listening, sends one real HTTP GET over loopback and stops the clock when the response has been fully received. The clock starts just before spawn(), so it covers everything: process creation, Node boot, parsing, compiling and evaluating the bundle, defining the API, building the handler, binding the socket, and serving a request with code V8 has never run before. APIs have 1, 10, 50, 100, 500 endpoints.

Server cold start, 1 endpoint
140 ms
spawn → HTTP response received · p95 143 ms
Server cold start, 500 endpoints
218 ms
p95 227 ms · 308 ms CPU across all threads
Added per endpoint
154 µs
linear (R² 0.999); the same work costs 82 µs once code is hot
Cold-code penalty
53%
of define + build + first request at 500 endpoints (94% at 1) is V8 running code for the first time

Summary. Starting a minimal HttpApi server and getting a response back takes 140 ms: 39 ms to spawn and boot Node, 74 ms to load the bundle, 19 ms to define the API, build the handler and listen, and 8 ms for the first request. Each endpoint adds ~154 µs, so 500 endpoints take 218 ms. About 17 ms of every Node server cold start is Node initializing undici for web APIs the server never uses (§4). The biggest finding is that most of the post-load time is not the work itself but running it cold: rebuilding the same API in the same process once V8 has compiled and optimized the code takes 1.0 ms instead of 15.5 ms at 1 endpoint and 43 ms instead of 90 ms at 500. Cold start is therefore driven as much by how much distinct code runs at startup as by how much work it does.

1 · Anatomy of a cold start

Every phase from spawn() to the HTTP response arriving back at the client, as medians and in execution order. Spawn is process creation plus the time for the "listening" line to reach the client (measured with an empty script: 37.9 ms to its first output line, which also includes Node boot). Define API is HttpApi.make(…).add(…) with all its schemas. Build handler + listen is HttpApiBuilder plus building the HttpRouter.serve layer on NodeHttpServer until the socket is bound. First HTTP request is the client's round trip for one GET, of which 6.2 ms is spent inside the server.

Server cold start: spawn → first HTTP response

median ms · hover for values

Table view
EndpointsSpawnNode bootModule loadDefineBuild + listenFirst HTTP requestCold start p50p95CPU (all threads)Bundle KB
111.927.373.82.217.17.6140.3142.9146.1765
1012.027.373.63.419.17.5142.5145.9150.1768
5012.527.372.97.920.37.5148.6158.6157.4782
10013.027.374.210.824.87.6158.5162.3175.4800
50013.127.376.135.554.810.1217.9227.4308.4944

Web-handler mode (Worker-style)

The same app as a web handler (HttpRouter.toWebHandler, platform-neutral bundle, request passed in-process as a Request), which is how it would run on Cloudflare Workers or another fetch-based runtime. There is no spawn, socket or Node server stack, and the clock runs from process start to the response body. This mode is also what the CPU profiles and the cold/warm experiment below use.

Web handler: process start → first response

median ms · module load split by CPU-profile shares · hover for values

Table view
EndpointsNode bootParse & compileModule evalundici (Node)DefineBuildFirst requestCold start p50p95CPU (all threads)
127.316.414.626.42.45.67.5100.2103.7113.4
1027.316.613.527.53.87.87.5104.5108.5119.1
5027.316.913.427.07.111.87.4112.6115.6129.9
10027.316.713.827.914.313.87.5120.0122.4143.3
50027.319.113.828.036.845.17.7179.3185.8245.7

2 · Cold code vs real work

To separate the cost of running code for the first time from the work itself, each process runs the cold pass and then rebuilds everything from scratch five more times in the same process: a new API definition, layer, handler and request each time. We report the last rebuild. The work is identical, but V8 has by then lazily compiled the functions, tiered them up from the interpreter and warmed inline caches, and any one-time lazy initialization has happened. Cold − warm is the cold-code penalty, a cost that exists only because it is a cold start.

Define + build + first request: work vs cold-code penalty

median ms, same process · hover for values

Table view
EndpointsDefine cold / warmBuild cold / warmFirst request cold / warmTotal cold / warmCold-code penalty
12.4 / 0.2 (10×)5.6 / 0.3 (17×)7.5 / 0.4 (17×)15.5 / 1.014.5 ms (94%)
103.8 / 0.6 (6.4×)7.7 / 0.7 (11×)7.4 / 0.5 (15×)18.9 / 1.817.1 ms (90%)
508.1 / 2.6 (3.2×)11.7 / 2.8 (4.2×)7.4 / 0.5 (15×)26.4 / 5.820.6 ms (78%)
10014.5 / 3.7 (4.0×)13.9 / 8.8 (1.6×)7.6 / 0.5 (14×)35.8 / 13.022.8 ms (64%)
50036.9 / 16.2 (2.3×)44.8 / 12.6 (3.5×)7.7 / 0.6 (12×)90.0 / 42.747.3 ms (53%)

Cold vs warm as the API grows

define + build + first request, median ms · hover for values

3 · How cold start grows with API size

Cold start grows linearly: ~154 µs per endpoint on top of ~103 ms fixed (R² 0.996). Define and build share the per-endpoint cost about evenly. Module load grows only with bundle size (+0.4 KB, +6.9 µs per endpoint), and the first request is flat.

Cold-start phases vs endpoint count

median ms · linear x axis · hover for values

Table view
EndpointsModule loadDefine APIBuild handlerFirst requestCold startCPU (all threads)Bundle KB
157.42.45.67.5100.2113.4552
1057.73.87.87.5104.5119.1555
5057.37.111.87.4112.6129.9569
10058.414.313.87.5120.0143.3587
50060.936.845.17.7179.3245.7731

Linear fit

Per endpointFixedR²
Cold start (wall)153.7 µs103.0 ms0.996
Cold start (CPU, all threads)260.6 µs115.8 ms0.999
Define API67.0 µs4.0 ms0.979
Build handler77.0 µs6.6 ms0.997
Module load6.9 µs57.4 ms0.968
First request0.4 µs7.5 ms0.672
Define+build+first, warm82.4 µs2.0 ms0.991
Bundle (unminified)0.4 KB551 KB1.000

Marginal cost (define + build + first)

µs per added endpoint between consecutive sizes. It is high for the first endpoints, while everything is still cold, then flat.

Stepµs / endpoint
1 → 10396
10 → 50186
50 → 100178
100 → 500138

What the per-endpoint cold cost is spent on

CPU profiles (5 per size) are split into phases using monotonic timestamps from the harness, and samples are attributed to Effect source files via sourcemaps. Category times are profile shares scaled to the un-profiled medians, and the per-endpoint figure is each category's linear-fit slope.

Cold per-endpoint cost by category

µs per added endpoint · hover for values

Table view
CategoryDefine API µs/epBuild handler µs/ep
Router (FindMyWay)0.022.2
HttpApiBuilder0.012.9
HttpApi definition12.02.4
Schema22.612.5
Effect runtime & Layer0.07.8
pipe / dual / predicates9.07.8
Garbage collector6.57.1
Other16.74.2
Total67.077.0

Build handler, by size

ms (scaled)

Table view
Category1 ep10 ep50 ep100 ep500 epµs / endpoint
Router (FindMyWay)0.61.21.72.411.822.2
HttpApiBuilder0.81.22.12.57.512.9
HttpApi definition0.00.30.30.61.42.4
Schema0.30.51.31.96.712.5
Effect runtime & Layer2.63.03.84.26.97.8
pipe / dual / predicates0.40.60.70.94.37.8
Garbage collector0.00.00.70.03.57.1
Other0.81.01.21.33.04.2

Define API, by size

ms (scaled)

Table view
Category1 ep10 ep50 ep100 ep500 epµs / endpoint
Router (FindMyWay)0.00.00.00.00.00.0
HttpApiBuilder0.00.00.00.00.00.0
HttpApi definition0.91.31.93.07.212.0
Schema1.11.93.16.713.122.6
Effect runtime & Layer0.00.00.00.00.00.0
pipe / dual / predicates0.20.10.41.14.69.0
Garbage collector0.00.00.31.33.36.5
Other0.30.51.42.38.816.7
Define: hottest functions at 500 endpoints
Function (source)ms (profiled)
(anonymous) (bundle):16.1
defineApi generated/api-50x10.mjs:75.8
(garbage collector) (garbage collector)4.6
make$16 schema/make.ts:463.3
(anonymous) src/Function.ts:15772.4
makeProto$2 http-api/HttpApiEndpoint.ts:8992.2
(anonymous) src/Function.ts:15401.5
validateResponseExclusivity http-api/HttpApiEndpoint.ts:13281.5
struct src/SchemaAST.ts:32731.2
copy src/SchemaAST.ts:43411.1
Build: hottest functions at 500 endpoints
Function (source)ms (profiled)
_on internal/router.ts:1168.0
on internal/router.ts:724.5
(garbage collector) (garbage collector)4.4
(anonymous) http-api/HttpApiBuilder.ts:1464.2
copy src/SchemaAST.ts:43412.3
(anonymous) src/Function.ts:15401.6
(anonymous) src/Function.ts:15771.4
handlerToHttpEffect http-api/HttpApiBuilder.ts:8271.2
(anonymous) src/Function.ts:2511.1
(anonymous) (bundle):11.1

4 · Module load: the fixed part

Module load takes ~57 ms at every size, before a single line of API code runs.

Inside module load

ms, profile shares scaled to the measured median, averaged over sizes

Table view
Componentmsshare
Parse & compile bundle17.230%
Effect module evaluation10.117%
Node: undici init27.448%
Garbage collector / other3.87%

Measured levers

Module load under each lever (web-handler bundle)

median ms · each lever measured on its own · 1-endpoint app, except the FormData A/B (10x10 app; module load barely depends on size)

Table view
LeverModule load (1 ep)SavingApplies to
As shipped57.4
Minify (rolldown/Oxc)44.5−13.0 msany runtime
Node compile cache (V8 code cache on disk)41.5−15.9 msNode (e.g. Lambda with a cache dir)
No globalThis.FormData at module scope28.2−29.6 msmoves to the first new Request() on Node; a server also needs the node:http fix (§4)
All sizes
EndpointsAs shippedMinifiedCompile cacheBundle KBMinified KB
157.444.541.5552220
1057.745.143.0555221
5057.345.142.8569229
10058.445.741.8587238
50060.947.942.8731312

Why every Node server pays for undici

Two independent things load undici during startup, each costing about 30 ms in isolation (Node v24.21.0):

  1. Schema.ts evaluates instanceOf(globalThis.FormData) at module scope. Touching FormData in an empty process costs 29.2 ms.
  2. An ESM import … from "node:http", which @effect/platform-node does. Node 24's http module exposes lazy, undici-backed WebSocket, CloseEvent and MessageEvent getters, and an ESM import snapshots every export, which triggers them: import("node:http") costs 32.2 ms, while process.getBuiltinModule("node:http") (or require) costs 4.7 ms.

Server cold start with the undici triggers removed

10x10 API, median of 25 runs · each variant patches the bundle

Table view
Server variant (10x10)Module loadCold startundici loaded?
As shipped71.8157.7yes
FormData ref removed71.3157.8yes
node:http via getBuiltinModule71.4156.5yes
Both removed54.1140.6no

Removing either trigger alone changes nothing, because the other one still loads undici. Removing both cuts server cold start by 17 ms (157.7 → 140.6 ms), and undici is never loaded. In web-handler mode the FormData reference is the only trigger: removing it drops module load from 58 to 28 ms, but the cost reappears in the first new Request() (7.5 → 34.4 ms), so on Node the total doesn't change; on workerd these globals are native and the cost doesn't exist.

A/B detail: web-handler mode
VariantModule loadDefineBuildFirst requestTotal
As shipped57.814.313.37.5120.3
FormData ref removed28.215.714.434.4121.1

What is in the bundle

KB of output per source category (unminified)

Table view
Category1 ep500 ep
Router (FindMyWay)22.822.8
HttpApi (definition + builder)66.266.3
Schema156.6156.6
HTTP stack (effect/http)100.8100.8
Stream / Channel20.120.1
Effect runtime & Layer89.189.1
Other Effect modules92.792.7
App code + harness3.9182.5

5 · Where to cut cold start

  1. Defer per-route setup to the first hit on that route. Build is 77 µs/endpoint and is paid upfront for every route. Compiling a route's codecs and handler pipeline (handlerToRoute, SchemaAST.copy/make) on first request would make cold start pay only for the routes a fresh instance actually serves.
  2. Execute less distinct code at startup. 94% (small) to 53% (large) of post-load time is cold-code penalty. Fewer generic layers on the define/build path (dual/pipe wrappers, Effect-runtime-driven layer construction for what is synchronous setup) cut both the work and the code that has to be compiled.
  3. Cheaper route registration. FindMyWay._on is the top self-time function in build. Its per-segment split/join and toLowerCase could be skipped for plain segments, or the tree could be built lazily on first lookup.
  4. Stop loading undici in Node servers (−17 ms per cold start, measured). Two changes are needed together: make the FormData/File guards in Schema.ts read the global lazily inside the check, and have @effect/platform-node obtain node:http through process.getBuiltinModule("node:http") (Node ≥ 20.16 / 22.3) instead of a static ESM import.
  5. For users: minify (−13 ms), enable the compile cache where the platform allows (−16 ms on Node), and reuse schema values across endpoints.

Methodology

Appendix

Not part of cold start: OpenAPI generation. OpenApi.fromApi costs 151 µs/endpoint (84 ms at 500 endpoints), but HttpApiScalar, HttpApiSwagger and openapiPath all build it lazily and memoize it, so only the first /docs request on an instance pays it.

Raw medians / p95 (ms) for every phase
EndpointsevaldefinelayerhandlerfirstsecondopenapiCPU @1st respheap MB
184.7 / 88.32.4 / 2.40.2 / 0.25.4 / 5.47.5 / 7.60.7 / 0.76.1 / 6.3113.4 / 116.510.4 / 10.4
1084.9 / 89.33.8 / 3.90.2 / 0.27.5 / 9.47.5 / 7.60.6 / 0.710.1 / 13.2119.1 / 124.711.0 / 11.1
5084.6 / 88.07.1 / 10.70.4 / 1.611.4 / 11.87.4 / 7.50.6 / 0.617.0 / 17.8129.9 / 133.214.0 / 14.0
10085.7 / 88.514.3 / 14.70.4 / 0.513.4 / 13.67.5 / 7.80.6 / 0.725.0 / 25.7143.3 / 145.516.3 / 16.4
50088.1 / 92.736.8 / 40.41.3 / 1.443.8 / 45.47.7 / 7.90.6 / 0.783.6 / 88.3245.7 / 253.527.0 / 27.5

Reproduce: node scripts/collect.mjs && node scripts/experiment-undici.mjs && node scripts/render-report.mjs · source: github.com/mikearnaldi/benchmark-http-api-boot