Debugging Tests That Time Out Only in CI
The integration suite passes in forty seconds on a laptop and fails in CI with Error: Exceeded timeout of 5000 ms for a test or thrown: "Exceeded timeout of 5000 ms for a hook" — in different tests each run. Retrying sometimes helps, which makes it tempting to raise every timeout to 60 seconds and move on. But CI-only timeouts nearly always have a specific environmental cause: fewer CPUs than the test runner assumes, services that are started but not ready, DNS lookups to hosts that do not resolve on the runner, or memory pressure. This page measures which one applies and fixes it, as part of CI/CD pipeline parity checks.
The aim is to make CI as predictable as the laptop — not to hide slowness behind larger timeouts.
Diagnostic
Record the runner's resources and the test runner's parallelism, and time the slowest tests:
#!/usr/bin/env bash
set -euo pipefail
echo "cpus: $(nproc) mem: $(free -g | awk '/Mem/{print $2}') GiB load: $(cut -d' ' -f1-3 /proc/loadavg)"
node -e "console.log('os.availableParallelism', require('os').availableParallelism())"
docker compose ps --format '{{.Service}} {{.State}} {{.Health}}'
npx vitest run --reporter=json --outputFile=/tmp/results.json || true
jq -r '.testResults[].assertionResults[] | select(.duration > 2000) | "\(.duration)ms \(.fullName)"' /tmp/results.json | sort -rn | head -5
Expected bad output on a hosted runner:
cpus: 4 mem: 15 GiB load: 11.84 9.02 5.13
os.availableParallelism 4
db running starting
cache running
5012ms orders > creates order with discount
4988ms orders > setup hook
3120ms checkout > applies coupon
The load average is nearly three times the CPU count, the database is still starting, and the slowest tests are the first ones to touch it.
Root cause
Four differences explain most CI-only timeouts. CPU: test runners size their worker pools from the host's CPU count or default to aggressive parallelism, and CI runners have fewer cores than developer laptops — often shared with Docker services on the same machine — so each worker runs several times slower. Readiness: CI starts services and tests in quick succession; locally the stack has usually been running for hours. A depends_on without a health condition, or tests that start before docker compose up finishes, send the first queries to a database that is still initialising. Network: tests that call external hosts, or resolve names that exist only on a developer's network, wait for DNS or TCP timeouts in CI. Memory: with many workers plus services, the runner swaps, and everything slows unpredictably. Raising timeouts treats all four as one and leaves the suite slow and flaky.
Resolution
- Size the worker pool to the runner. Limit parallelism explicitly in CI rather than letting the runner guess:
#!/usr/bin/env bash
set -euo pipefail
workers=$(( $(nproc) > 2 ? $(nproc) - 1 : 1 ))
npx vitest run --pool=threads --poolOptions.threads.maxThreads="$workers"
For Jest, --maxWorkers=50%; for pytest-xdist, -n $(nproc) minus the cores the services need.
- Wait for real readiness before tests start. Use healthchecks and
--waitso tests never race service start-up:
services:
db:
image: postgres:16.4
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d shop && psql -U postgres -d shop -c 'select 1' >/dev/null"]
interval: 2s
retries: 60
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --wait db cache
npm run db:migrate
npx vitest run
- Remove network dependencies from tests. Find outbound calls and replace them with local stubs; block the network during tests to catch new ones:
#!/usr/bin/env bash
set -euo pipefail
docker compose run --rm --network none api-tests npx vitest run 2>&1 | grep -E 'ENOTFOUND|EAI_AGAIN|ECONNREFUSED' | sort | uniq -c || echo "no network calls from tests"
The external API mocking topic covers the stubs.
- Give services memory limits and check swap. Constrain service containers so they cannot starve test workers, and fail fast if the runner swaps:
#!/usr/bin/env bash
set -euo pipefail
swap_used=$(free -m | awk '/Swap/{print $3}')
[ "$swap_used" -lt 200 ] || echo "warning: runner is swapping (${swap_used} MiB); reduce workers or service memory"
- Only then set explicit, justified timeouts for the few tests that are legitimately slow, next to the test with a comment explaining why.
Expected output
cpus: 4 mem: 15 GiB load: 3.21 2.87 2.10
db running healthy
cache running healthy
Test Files 38 passed (38)
Tests 412 passed (412)
Duration 71.42s
Load stays below the CPU count, services are healthy before the first test, no test exceeds its timeout, and the suite takes about 70 seconds — slower than a laptop, but consistently.
Consistency is the real improvement. A suite that takes 70 seconds every run is easy to reason about; one that takes 40 seconds most of the time and times out every fifth run erodes trust in CI, trains people to press "re-run", and hides genuine regressions among the noise. Recording the suite duration per run and alerting when it jumps keeps it that way.
Prevention
Record test durations in CI and fail or warn when a test's duration grows by several times its median, which catches new slow tests before they become flaky.
Run the suite locally under CI-like limits when investigating:
docker run --cpus=4 --memory=8greproduces the resource shape of a hosted runner.Ban
sleepin tests in favour of waiting on conditions; sleeps tuned on a fast laptop are exactly the ones that break on a slow runner.
Platform caveats
Hosted runners: CPU counts differ between runner types (2 to 4 vCPUs is common on free tiers) and are shared with Docker services. Measure with
nprocin the job rather than assuming.
macOS runners: Docker is not available on hosted macOS runners by default; integration tests that need services should run on Linux runners.
Apple Silicon (ARM64) laptops: local runs on M-series machines are often several times faster than CI; use
--cpuslimits locally to approximate CI when reproducing timeouts.
Rollback
Revert worker limits or healthcheck changes if they cause regressions; they are independent:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- vitest.config.ts compose.yaml .github/workflows/test.yml
Frequently Asked Questions
Why do tests time out only in CI?
Usually because CI runners have fewer CPUs, services have only just started, tests call hosts that do not resolve there, or the runner is under memory pressure. Measure load, service health and network calls to find which.
Should I just increase the test timeout?
Only for tests that are legitimately slow, with a comment explaining why. Raising timeouts globally hides the cause, keeps the suite flaky under load and makes failures slower to report.
How many test workers should CI use?
Roughly the runner's CPU count minus what the services need — often nproc - 1. More workers than cores increases contention and makes every test slower.
How can I reproduce CI timing on my laptop?
Run the suite in a container with CPU and memory limits similar to the runner (--cpus=4 --memory=8g) and a freshly started service stack, rather than one that has been running for hours.