start-server-and-test solves the oldest race condition in end-to-end testing: it starts your server command, polls a URL until it actually responds, only then runs your test command, and shuts the server down when the tests exit. Written and maintained by Gleb Bahmutov, it replaced a generation of npm start & sleep 30 && npm test hacks that made CI pipelines slow when the sleep was long and flaky when it was short. This guide covers the invocation syntax that trips people up, waiting on multiple services, timeout tuning, and the CI hygiene questions — including why a test-orchestration utility still belongs in your dependency review.
The core pattern
Install as a dev dependency and wire it into npm scripts:
npm install --save-dev start-server-and-test
{
"scripts": {
"start": "node server.js",
"cy:run": "cypress run",
"test:e2e": "start-server-and-test start http://localhost:3000 cy:run"
}
}
The three positional arguments are: the npm script (or shell command) that starts the server, the URL or port to wait on, and the npm script to run once the URL responds. When cy:run exits, the tool kills the server process tree and propagates the test exit code — which is what makes it CI-safe: a failing test fails the job, and no orphaned server process hangs the runner.
The package installs three equivalent binaries — start-server-and-test, server-test, and start-test — so start-test 3000 cy:run is valid shorthand when your server script is literally named start.
The wait URL is where flakiness hides
Under the hood the tool polls using the wait-on package, and the URL scheme you choose changes the probe semantics. This is the detail that generates most of the project's support issues:
http://localhost:3000— sends HEAD requests. Some dev servers and frameworks do not answer HEAD, so the probe never succeeds and the run times out even though the server is up.http-get://localhost:3000— sends GET requests. This is the safe default for real-world servers; use it whenever plainhttp://mysteriously hangs.https-get://localhost:3000— GET over TLS. For local self-signed certificates, setSTART_SERVER_AND_TEST_INSECURE=1so the probe accepts the untrusted cert without disabling TLS validation process-wide.- A bare port (
3000) — waits for the TCP port to accept connections, which proves the socket is open but not that the app can serve a request. Prefer probing a real route.
The strongest pattern is to point the probe at a route that exercises readiness, not liveness:
"test:e2e": "start-server-and-test start http-get://localhost:3000/healthz cy:run"
If your app answers /healthz only after database migrations and route registration complete, the tests start exactly when the app is genuinely ready — no sleep guessing, no "connection refused" retries polluting the first spec.
Multiple services, one command
Modern E2E setups rarely have one server. The tool composes by taking additional start/wait pairs:
{
"scripts": {
"start:api": "node api/server.js",
"start:web": "vite preview --port 8080",
"test:e2e": "start-server-and-test start:api http-get://localhost:3030/healthz start:web http-get://localhost:8080 cy:run"
}
}
Services start sequentially — the web app does not launch until the API's URL responds — which conveniently encodes dependency order. For services that must start in parallel, keep using docker compose or your CI's service containers and reserve start-server-and-test for the application under test itself. Mixing the two is fine: compose brings up Postgres and Redis as job services, start-server-and-test handles the app.
Timeouts are governed by START_SERVER_AND_TEST_TIMEOUT (milliseconds, default five minutes). Cold-cache builds in CI can legitimately exceed it when the "start" script includes compilation; either raise the timeout or, better, build in a prior CI step and make the start script serve prebuilt output. Debug hangs with the DEBUG environment variable:
DEBUG=start-server-and-test npm run test:e2e
which logs each probe attempt so you can see whether the server never bound the port, bound it late, or is rejecting the probe method.
CI hygiene around the utility
A test orchestrator seems like an odd subject for dependency review, but it sits in a privileged spot: it executes arbitrary commands, runs in CI with access to whatever secrets the job exposes, and its output shapes whether a pipeline passes. Three practical notes.
It is a devDependency with real transitive weight. The tool pulls in wait-on and its HTTP stack. Build-time and test-time packages do not ship to users, but they run with your CI credentials — the Codecov bash uploader incident made that threat model concrete. Keep dev tooling in scope for scanning rather than filtering it out; an SCA platform like Safeguard distinguishes exposure contexts (runtime vs. build) rather than ignoring one of them, which is the right resolution — a vulnerable dev tool is lower severity than a vulnerable runtime package, not zero severity.
Pin it like everything else. Exit-code propagation is the load-bearing feature: a regression that swallowed a non-zero test exit would turn every red build green. Lockfile discipline plus PR-based updates gives you a diff to look at when the tool itself updates.
Do not let it mask server crashes. If the server exits during the test run, tests start failing with connection errors that look like test flakiness. When triaging, check the server's own output first — the tool interleaves both processes' logs in the job output, and the server.verbose patterns in its README help separate them.
Where it fits vs. alternatives
- Cypress, Playwright, and WebdriverIO docs all recommend it or an equivalent for the local and simple-CI case. Playwright users have a native alternative: the
webServeroption inplaywright.config.tsdoes the same start-wait-teardown dance inside the runner, withreuseExistingServerfor local development. If you are Playwright-only, prefer the built-in; if the same npm script must serve Cypress, Playwright, and a smoke-test script, start-server-and-test stays the common denominator. - docker compose with healthchecks covers the multi-container case and encodes readiness in the image itself (
condition: service_healthy). Heavier, but the readiness definition travels with the service rather than living in an npm script. - Raw
wait-onis the right primitive when you only need the waiting, not the process management — for example, waiting for a compose stack before running tests the stack does not own.
End-to-end tests are also where dynamic security testing naturally attaches: the same "app is up, hit it with requests" choreography that runs your Cypress specs can run an authenticated DAST scan against the ephemeral environment, reusing the readiness probe you already wrote.
FAQ
What does start-server-and-test actually do?
It runs your server command, polls a URL (via wait-on) until it responds, runs your test command, then kills the server process tree and exits with the test command's exit code. It removes the race between server startup and test start.
Why does my run hang even though the server is up?
Almost always the probe method: plain http:// URLs send HEAD requests, which some servers ignore. Switch to http-get://localhost:PORT to probe with GET, and point at a route that returns 200 when the app is ready.
Can start-server-and-test wait for more than one server?
Yes — pass additional start-script/URL pairs: start-server-and-test start:api 3030 start:web 8080 cy:run. Services start sequentially, each waiting for the previous one's URL to respond.
Is there a built-in alternative in Playwright?
Yes. Playwright's webServer config option starts a command, waits for a URL or port, and reuses an existing server locally. If Playwright is your only consumer, the built-in option removes a dependency; start-server-and-test remains useful when multiple tools share one startup flow.