Why did the first request take eight seconds?

A newly deployed subdomain took 8.1 seconds to first byte, then was fast. We broke container cold start into five measured phases. The whole in-process budget is 448 ms. The seconds live outside the process — in the image pull and the healthcheck interval.

Why did the first request take eight seconds?

The container was serving in 448 milliseconds. Everything else was outside the process. We measured every phase of a Node service starting in a container, and the entire in-process budget — daemon round trip, Node bootstrap, importing fifty packages, binding the port, connecting to Postgres, V8 warming up — comes to 448 ms at its worst. An 8.1-second first byte cannot be built out of those parts. It has to come from the image pull or from something waiting on a healthcheck, and both of those are measured below too.

This started with a real deployment. A new product subdomain on our 4-CPU / 7.7 GB VPS answered its first request in 8.1 seconds and every subsequent one in tens of milliseconds. Several more products are about to land on that same box, so "it warms up eventually" was not an acceptable answer.

The short answer

  • docker run to your first line of JavaScript is 94 ms detached, 173 ms attached, and it does not change with image size, dependency count, or anything you control in the app. Roughly 24 ms of it is the CLI reaching the daemon at all, and 79 ms is attaching to stdio — which your orchestrator does not do, and your benchmark script probably does.
  • Dependencies are the only startup cost that scales with your code. Requiring 50 npm packages — 1,763 files — took 265 ms; ten packages (78 files) took 81 ms; zero took 0. That is about 0.15 ms per file.
  • A bigger image does not start slower once it is pulled. An 8.66 MB alpine and a 1.11 GB image built from the same app started in 237 ms and 236 ms. A 130x size difference produced no measurable difference.
  • JIT warm-up is one request, not a thousand. The first request ran the handler in 1,857 µs against a steady state of 632 µs — 2.9x — and request number two was already within 10% of steady.
  • The healthcheck interval is the user-visible cold start. The same container opened its port in ~170 ms and was reported healthy at 1.2 s, 5.2 s or 30.2 s, depending only on --health-interval. If a proxy waits for healthy, that is your cold start.

What I measured, and on what

MacBook Air, Apple M3 (4 performance + 4 efficiency cores), 16 GB, macOS 26.4.1 (build 25E253). Docker client and server 29.5.2, Docker Desktop's Linux VM on kernel 6.12.76-linuxkit, 8 vCPUs, 7.75 GiB guest RAM, overlay2. The service image is node:22-slimNode v22.23.2, V8 12.4.254.21-node.56, arm64. Postgres 16 ran in a second container on the same bridge network.

Everything is timed from inside the process using performance.timeOrigin — the epoch millisecond at which the Node process started — against a wall-clock stamp taken on the host immediately before docker run. That splits "time Docker spent" from "time my code spent". Each figure is the median of five runs unless stated.

This is a Mac, so it is a Linux VM. Our earlier measurement of Docker's overhead found that on macOS the expensive boundary is the VM, not the container, and that applies here: the 165 ms docker run figure includes a macOS-to-VM socket hop that does not exist on a Linux server. Everything measured inside the VM — imports, listen, JIT, healthcheck timing — holds virtualisation constant and should transfer.

Where does the time actually go?

Five phases, measured separately, for a service importing 50 packages and connecting to Postgres at startup:

Phase Median What it is Scales with
docker run → process start 165 ms CLI → daemon → containerd → exec nothing you control
Process start → your first line 12 ms Node bootstrap, V8 init, snapshot Node version
require / import 265 ms 1,763 files read, parsed, executed dependency count
Imports → listening 3 ms server.listen() nothing
Startup DB connect (eager) 7 ms TCP + auth on a warm pool network RTT
Total, docker run → accepting connections 448 ms
First request, first byte 2.3 ms JIT + handler
Steady-state request 1.3 ms handler

That is the whole thing. Under half a second, and the largest single piece is require. (The 165 ms first row is docker run attached to stdio, which is how the harness reads the timings out; detached it is 94 ms, and the section below takes that apart.)

What does docker run itself cost?

An earlier CST article put docker run --rm alpine true at 272 ms. Re-measuring on Docker 29.5.2, ten runs:

docker run --rm alpine true              min 161.3  p50 193.2  max 353.3
docker run --rm node:22-slim true        min 162.1  p50 178.5  max 195.5
docker run --rm node:22-slim node -e 0   min 169.7  p50 209.0  max 276.7
docker version (CLI -> daemon only)      min  19.7  p50  24.1  max  50.2

Same order of magnitude, and the spread between runs is wider than most of the differences people argue about. 24 ms goes on the CLI reaching the daemon before any container exists, and Node's own interpreter startup is only about 30 msnode -e 0 costs 209 ms where true costs 178 ms.

Not all of the rest is unavoidable. Creating a container is a separate cost from starting one:

docker start (container already created)  min 89.3  p50  95.4  max 108.3
docker pause + unpause                    min 53.6  p50  67.1  max  73.8

docker start on an existing container is 95 ms against ~165 ms for run. Container creation is about 70 ms of the bill, which is why scale-to-zero schemes that keep stopped containers around beat ones that recreate them.

One more piece of it is free. Every measurement above attaches to the container's stdio, because that is how the harness reads the phase line. Running detached does not:

docker run -d (detached)      min  85.5  p50  93.8  max 184.5
docker run    (attached)      min 157.3  p50 172.7  max 188.1

Attaching stdio costs 79 ms. It is measured to the process's own timeOrigin, so it delays process start rather than just the report. Your orchestrator starts containers detached, so the real floor is nearer 94 ms than 165 ms. If you have benchmarked cold starts by timing docker run in a shell, you were partly measuring your terminal.

What do dependencies actually cost?

The same service, importing 0, 10 or 50 packages from an already-installed node_modules:

Top-level packages Files loaded Import time run → listening Idle RSS
0 0 0 ms 201 ms 9.3 MiB
10 78 81 ms 271 ms
50 1,763 265 ms 446 ms 77.5 MiB

It is the file count that matters, not the package count: 265 ms over 1,763 modules is 0.15 ms each. Bundling the server into one file, or lazy-importing routes you may not need, is the one startup optimisation that moves this number.

On a shared box the memory column matters more than the milliseconds. Fifty dependencies cost 8x the resident memory of none, and if six products share 7.7 GB, that is the constraint that bites first.

Is the first request slow because of the database?

Two services: one creates its Postgres pool on first use, one connects before it calls listen().

Variant run → listening Request 1 Request 2 Steady
No database 183 ms 3.3 ms 1.7 ms 1.2 ms
Lazy connect 189 ms 27.0 ms 2.5 ms 1.3 ms
Eager connect 187 ms (incl. 18 ms connecting) 3.3 ms 2.1 ms 1.3 ms

Lazy costs the first caller 24 ms extra; eager costs everyone 18 ms of startup nobody sees. Same work, different victim, and eager is the right default behind a load balancer.

The interesting part is what that 24 ms is made of. Repeat it with pg already imported at boot — it was one of the 50 packages — and the numbers collapse:

Variant (50 packages, pg already loaded) Connect at boot Request 1
Lazy connect 0 ms 7.0 ms
Eager connect 7 ms 2.3 ms

Most of the "lazy connection" penalty was require('pg'), not the connection. Loading the driver is ~11–13 ms; opening the socket and authenticating one bridge hop away is ~5–7 ms. On a real network the connect half grows and the require half does not, so measure both before deciding what to move.

Is the first request slow, or the first thousand?

The folklore says V8 must see a function run many times before it optimises it, so early requests are all slow. We timed the handler itself — process.hrtime.bigint() around the request body, no HTTP in the number — for 3,000 sequential requests, three times over.

Request # Handler Wall clock
1 1,857 µs 3.4 ms
2 695 µs 1.9 ms
3 691 µs 1.8 ms
4–10 682 µs 1.6 ms
11–50 661 µs 1.3 ms
51–100 638 µs 1.3 ms
101–300 637 µs 1.2 ms
301–1,000 629 µs 1.1 ms
2,001–3,000 632 µs 1.1 ms

Request one is 2.9x steady state. Request two is within 10%. The remaining 9% takes until about request 100 and is not worth anyone's attention.

The honest version of the JIT story is therefore: V8 warm-up costs 1.2 milliseconds, once. Real, reproducible, and 0.015% of an 8.1-second first byte. Anyone blaming a multi-second cold start on JIT is wrong by three orders of magnitude. Pre-warm the module graph, not the optimiser.

Does a bigger image start slower?

This assumption is behind a lot of Dockerfile golf, so we tested it. Four images, twelve docker run --rm IMAGE true each, interleaved so drift hits all of them equally:

Image Size on disk Layers p50 mean
alpine:latest 8.66 MB 1 237.1 ms 236.6 ms
our app on node:22-slim 310 MB 8 238.9 ms 246.8 ms
same app + an 800 MB layer 1.11 GB 9 235.9 ms 232.6 ms
node:22 1.13 GB 8 237.9 ms 251.6 ms

No difference. None. A 130x range in image size produced numbers that sit inside each other's noise. The first pass at this measurement, run non-interleaved, showed the fat image 76 ms slower — that was drift, and interleaving erased it. Worth saying plainly, because it is the sort of result that gets published.

The mechanism is obvious once you see it: overlay2 mounts layers, it does not read them. Nothing ever opened the 800 MB file, so its bytes cost exactly nothing. Image size is a pull cost and a disk cost, not a start cost.

Small images are still worth having — our Alpine versus slim comparison has the case — but "it starts faster" is not one of the reasons.

So what did take eight seconds?

Two candidates, both measured, both outside the process.

The pull. Three images over one home connection, registry connection warm:

Image Compressed download docker pull
alpine:3.19 3.4 MB 5.29 s
python:3.13-slim 46.6 MB 2.58 s
node:20 389.4 MB 29.04 s

There is a fixed few-second registry handshake — the very first pull of alpine:3.19, before any connection was warm, took 18.82 s for 3.4 MB — and after that it is bandwidth. A 389 MB image took 29 seconds, 65x the entire in-process cold start. This only happens on a first deploy or a new host, but when it does it is the only number that matters, and it is the one case where image size genuinely decides your cold start.

The healthcheck. Same image, same app, only --health-interval changed:

--health-interval Port accepting connections Docker reports healthy
1s 245 / 176 / 164 ms 1,280 / 1,238 / 1,184 ms
5s 165 / 171 / 181 ms 5,224 / 5,213 / 5,214 ms
30s 132 / 172 / 184 ms 30,219 / 30,239 / 30,225 ms

The service was ready in about 170 ms every time. Docker declared it healthy at 1.2, 5.2 or 30.2 seconds, because the first probe does not run until one full interval has elapsed. If your proxy holds traffic until the container is healthy, your cold start is the healthcheck interval, to the nearest millisecond. An 8.1-second first byte is entirely consistent with a 5- or 10-second interval and one missed probe.

The fix is not a short interval everywhere — that is a probe hitting every container on the box forever. Use --health-start-period with a short --health-start-interval, so early probes are aggressive and steady-state ones are not, and make sure the probe is checking readiness rather than liveness.

Is keeping it warm cheaper than starting it?

We read process.cpuUsage() from inside the container at four points:

CPU after boot + listen:                 39.4 ms
CPU after the first real request:        45.4 ms  (+6.0 ms)
CPU after 30 s completely idle:          79.3 ms  (+33.9 ms over 30 s)
CPU after 30 keep-warm pings over 30 s: 148.6 ms  (+69.3 ms)

A cold start costs about 45 ms of process CPU. An idle warm container burns 1.13 ms of CPU per second — 0.11% of one core — so it takes about 40 seconds of idling to spend what one cold start costs. A keep-warm ping is about 1.2 ms of CPU each.

So keeping a container warm beats cold-starting it, in CPU, only if requests arrive more often than once every 40 seconds. But CPU is the wrong currency on a 4-CPU / 7.7 GB VPS: a warm container with 50 dependencies loaded holds 77 MiB of RSS, and six of those is 460 MB of a 7.7 GB box doing nothing. Keep things warm because 448 ms matters to a user, not to save CPU.

What would be different on Linux

The docker run figure should shrink on a native Linux host — part of it is the macOS-to-VM socket hop, the 24 ms CLI round trip in particular. Import time may improve on a real filesystem rather than overlay2 inside a VM, though the files were in page cache either way here.

Three things I could not test and will not extrapolate: pull throughput from the VPS's own network; layer reads from genuinely cold page cache on a real disk, the one condition under which image size might touch start time; and any of this under contention from five other containers on four cores. The healthcheck, the JIT curve and the import cost are timer- and CPU-bound, and should transfer unchanged.

Check it yourself

Under two minutes, no repo to clone. Copy this into a file and run it.

#!/usr/bin/env bash
set -euo pipefail
D=$(mktemp -d); cd "$D"

cat > srv.js <<'JS'
const t0 = performance.timeOrigin, http = require('http');
const N = parseInt(process.env.DEPS || '0', 10), a = Date.now();
for (let i = 0; i < N; i++) require('node:' + ['fs','url','zlib','crypto','dns'][i % 5]);
const b = Date.now();
http.createServer((q, r) => r.end('ok')).listen(3000, () =>
  console.log('PHASE ' + JSON.stringify({ origin: t0, imports: b - a, listen: Date.now() })));
JS

cat > hc.js <<'JS'
require('http').get({host:'127.0.0.1',port:3000},r=>process.exit(r.statusCode===200?0:1))
  .on('error',()=>process.exit(1));
JS

printf 'FROM node:22-slim\nWORKDIR /app\nCOPY srv.js hc.js ./\nCMD ["node","srv.js"]\n' > Dockerfile
docker build -q -t cold-demo:1 . >/dev/null

echo "--- docker run -> process start -> imports -> listening ---"
for DEPS in 0 5 25; do
  for i in 1 2 3; do
    docker rm -f -v cold-demo >/dev/null 2>&1 || true
    T0=$(python3 -c 'import time;print(int(time.time()*1000))')
    docker run -d --name cold-demo -e DEPS=$DEPS cold-demo:1 >/dev/null
    until docker logs cold-demo 2>/dev/null | grep -q PHASE; do sleep 0.02; done
    docker logs cold-demo 2>/dev/null | grep PHASE | python3 -c "
import sys, json
p = json.loads(sys.stdin.read().split('PHASE ', 1)[1])
print('  DEPS=%-2s run->proc %4d ms | imports %3d ms | ->listen %3d ms | TOTAL %4d ms'
      % ('$DEPS', p['origin'] - $T0, p['imports'],
         p['listen'] - p['origin'] - p['imports'], p['listen'] - $T0))"
  done
done
docker rm -f -v cold-demo >/dev/null

echo "--- healthcheck interval == perceived cold start ---"
for IV in 1s 10s; do
  docker rm -f -v cold-hc >/dev/null 2>&1 || true
  S=$(python3 -c 'import time;print(time.time())')
  docker run -d --name cold-hc --health-cmd 'node /app/hc.js' --health-interval=$IV \
    --health-timeout=2s --health-retries=1 --health-start-period=0s cold-demo:1 >/dev/null
  until [ "$(docker inspect -f '{{.State.Health.Status}}' cold-hc)" = healthy ]; do sleep 0.05; done
  python3 -c "import time;print('  --health-interval=%-4s -> healthy after %5.2f s' % ('$IV', time.time()-$S))"
  docker rm -f -v cold-hc >/dev/null
done

docker image rm -f cold-demo:1 >/dev/null; cd /; rm -rf "$D"

What it printed here:

--- docker run -> process start -> imports -> listening ---
  DEPS=0  run->proc  110 ms | imports   0 ms | ->listen  18 ms | TOTAL  129 ms
  DEPS=0  run->proc   87 ms | imports   0 ms | ->listen  17 ms | TOTAL  105 ms
  DEPS=5  run->proc   83 ms | imports   2 ms | ->listen  16 ms | TOTAL  102 ms
  DEPS=25 run->proc   87 ms | imports   2 ms | ->listen  13 ms | TOTAL  103 ms
--- healthcheck interval == perceived cold start ---
  --health-interval=1s   -> healthy after  1.22 s
  --health-interval=10s  -> healthy after 10.27 s

Two things to read out of it. run->proc is ~90 ms rather than 165 ms because the script starts containers detached. And imports stays at 2 ms even at DEPS=25, because the demo requires only Node builtins, which live in the V8 startup snapshot and are effectively free — it takes 1,763 files out of node_modules to reach 265 ms. The demo's job is the floor and the healthcheck gap, which is the whole article: everything you can optimise inside the process is worth a few hundred milliseconds, and one line of your compose file is worth thirty seconds.

Where this goes next

These are the numbers behind the deploys for Simple CRM and the Workflow Builder, which share one VPS behind one Caddy config. Three things changed after measuring: --health-start-period with a fast start interval, an eager database connect before listen(), and a build that bundles the server so 1,763 files become one.