cy.visit() Failed: Your Dev Server Is Not Running
Start your dev server before Cypress runs and match baseUrl to it.
20+ years shipping production backend systems. Drawn from code that ran under real load.
- ✓Node.js installed with your project's dev server runnable via npm
- ✓A Cypress project with cypress.config.js you can edit
- ✓Basic comfort reading CI job logs and running curl
- cy.visit() fails with ECONNREFUSED when no server listens on the URL's host and port — start the dev server first, then run Cypress
- Set baseUrl in cypress.config.js so cy.visit('/') resolves correctly, and override it per environment with CYPRESS_BASE_URL
- Never trust process-spawn as readiness — block Cypress with start-server-and-test or wait-on until the URL responds
- In CI, bind the host explicitly and add a curl health check so a dead server fails fast with a clear message
Think of Cypress as a food critic and your dev server as the restaurant. is the critic walking to the restaurant's address. If the restaurant is closed (server not running), at a different street than the critic wrote down (wrong baseUrl), or still unlocking the doors (not ready yet), the critic cannot review anything. The fix is not a better critic — it is confirming the restaurant is open before sending the critic over.cy.visit()
It is 9:40 AM, your pull request is green everywhere except the one Cypress job that matters, and the error is brutally unhelpful: failed trying to load some URL, connection refused. You did not change the test. You did not change the app. Rerun the job and it passes — or fails on a different spec this time. Congratulations, you have met the most common Cypress failure in existence, and it has almost nothing to do with Cypress.cy.visit()
In nearly every case, fails for one of three boring reasons: nothing is listening at that address, Cypress is aimed at the wrong address, or the server exists but is not ready yet. The browser asked the OS to open a connection, the OS said no, and Cypress reported exactly that. There is no retry that can fix a server that is not there, which is why re-running the spec feels like rolling dice.cy.visit()
This article turns that dice roll into a checklist. You will learn how baseUrl and CYPRESS_BASE_URL decide where visits go, how to prove in ten seconds whether the server is up, and how to wire the readiness gate — start-server-and-test or wait-on — so Cypress physically cannot start before your app answers. Follow it once and this entire failure category disappears from your CI forever.
Why cy.visit() Fails When the Dev Server Is Not Running
When fails, the browser is telling you something precise: it tried to open a connection to a host and port, and the operating system refused it. That refusal has exactly one meaning — no process is accepting connections there right now. It does not mean your test is wrong, your selectors are stale, or Cypress is broken. It means the restaurant is closed and the critic is standing outside an empty building.cy.visit()
The confusion comes from how close everything looks to working. Your code compiles. Your unit tests pass. Cypress launches, the browser opens, and then the very first visit dies. Because the failure appears inside a test runner, your instincts blame the test. Fight that instinct. Open a second terminal while Cypress is failing and run curl -v against the exact URL. If curl also fails, you have proven in five seconds that no test edit can help — the server is simply not there.
This happens most often for embarrassingly simple reasons. You forgot to run npm run dev. You started it in a terminal you later closed. You are on a fresh machine where dependencies were never installed so the server crashed on boot. Or CI started Cypress and the server at the same moment and Cypress won the race. Every one of these is an environment problem wearing a test-failure costume, and the fix is to confirm the server before you ever read the spec.
baseUrl vs Absolute URLs: How Cypress Resolves cy.visit()
The baseUrl setting in cypress.config.js is the prefix Cypress prepends to every relative visit. With baseUrl: 'http://localhost:3000', calling cy.visit('/login') navigates to http://localhost:3000/login. Call cy.visit('http://other-host:4000/x') with a full URL and baseUrl is ignored entirely for that visit. Most wrong-address failures are developers mixing these two modes without realizing which rule applies.
Ports drift silently as projects evolve. Create React App defaulted to 3000, Vite defaults to 5173, Angular to 4200 — and any of them can move when the port is busy or a config changes. If your specs hard-code cy.visit('http://localhost:3000/...') in twenty places while the app now boots on 5173, you get a suite where some specs pass (the relative ones) and others fail (the absolute ones), which looks like flakiness but is really just stale addresses.
The durable fix is a single source of truth: one baseUrl in config, relative visits everywhere, and absolute URLs only where a test genuinely targets a second service. When a visit fails, diff the config value against the address your server prints on boot — host and port, character by character. Nine times out of ten the mismatch is staring at you from those two lines.
CYPRESS_BASE_URL and Per-Environment Overrides
The CYPRESS_BASE_URL environment variable overrides the baseUrl from your config file for a single run, with no edits and no commits. Run CYPRESS_BASE_URL=https://staging.example.com npx cypress run and every relative visit now targets staging. This is the mechanism that lets one suite run against local, preview, staging, and production simply by changing the environment around it.
It only works if your specs cooperate. Any absolute URL in a spec bypasses baseUrl and therefore ignores the override — a suite full of cy.visit('http://localhost:3000/...') calls will keep hitting localhost no matter what the variable says. That is the classic reason staging runs mysteriously test a developer's laptop address. Audit for absolute visits before you trust environment overrides.
The second trap is assuming the variable exists where it matters. It is set on your laptop but missing in CI, or set in CI but missing in a teammate's shell, and suddenly identical commits behave differently per machine. Treat CYPRESS_BASE_URL like any other deployment input: declare it in each CI environment explicitly, echo its value at the start of the job, and assert in a support file that Cypress.config('baseUrl') is defined. A missing URL should fail the job in one line at second zero, not as forty cryptic visit errors.
Wait for Readiness: start-server-and-test and wait-on
Starting a server and waiting for it are two different events, and the gap between them is where flaky cy.visit() failures live. Frameworks like Vite and Next.js compile bundles, load routes, and bind the port in stages — a process can exist for 30 seconds before its first HTTP response. Launch Cypress alongside the server with a bare & or concurrently and you are racing the compiler on every run.
The fix is a readiness gate: a tool that polls the URL and only launches Cypress after it answers. start-server-and-test is the standard choice — it starts your dev command, waits for the resource, runs Cypress, then cleans up the server. wait-on is the lighter alternative when something else owns the server lifecycle and you only need the waiting step. Either one converts a timing race into a guaranteed ordering.
Note what the gate waits on: the HTTP resource, not the process. Polling http://localhost:3000 proves the port accepts connections and the app responds. Watching for a PID or a log line proves far less — a process can print cheerful startup banners minutes before it serves traffic. If your CI still flakes after adding the gate, check that you are polling the same host and port Cypress visits; gating on 127.0.0.1 while visiting localhost (or vice versa) re-opens the race through a DNS-shaped hole.
CI Failures: Ports, Hosts, and Health Checks
CI amplifies every local sloppiness because the machine is cold, slow, and shared. Dependencies install from scratch, the framework compiles with an empty cache, and Cypress starts the instant the previous step exits. A suite that passes on your warm laptop can fail every CI run simply because boot takes 40 seconds there and 4 seconds at your desk. The failure looks identical to a broken app, which sends the team chasing ghosts.
Harden the CI job in three layers. First, bind the host explicitly — --host 0.0.0.0 — so the server is reachable however the runner routes traffic, and log the bound address so the job output proves where it listens. Second, gate with wait-on plus a generous timeout (cold boots are slow) so Cypress cannot start early. Third, add a curl --fail step between the gate and Cypress: if the app itself is crashing on boot, you get a one-line network error naming the URL instead of forty failing specs.
Keep ports pinned per project and document them where the team will actually see them — the README and the workflow file, not tribal memory. When Docker enters the picture, verify the container port mapping (-p 3000:3000) and remember that localhost inside a container is not the host's localhost. Most Docker visit failures are fixed by binding 0.0.0.0 in the container and visiting the mapped host port from Cypress.
A Startup Checklist That Ends cy.visit Flakes
End this failure category with a checklist you run once and then encode into scripts so nobody relies on memory. Confirm the dev server boots cleanly with npm run dev and loads in a real browser at the documented URL. Confirm baseUrl in cypress.config.js matches that URL exactly, and that every spec uses relative visits. Confirm CYPRESS_BASE_URL is set in each CI environment and echoed at job start. Confirm the launch path goes through start-server-and-test or wait-on, never a bare background &. Confirm a curl health check sits between readiness and Cypress.
Then make the setup self-defending. Assert baseUrl in cypress/support/e2e.js so a missing config throws a named error before the first test. Log the resolved baseUrl via setupNodeEvents so every CI run records what the suite aimed at. Pin the port per project and reject drive-by changes to it in code review — a port change without updating config and docs is a guaranteed red pipeline.
Do this and failures stop being mysteries. When a visit fails in the future, the checklist tells you in seconds whether the server died (health check red), the address moved (logs disagree), or the app genuinely broke (server up, page down) — three different owners, three different fixes, zero guessing.cy.visit()
The Afternoon Every Cypress Job Failed Because the Dev Server Was Still Booting
cy.visit() with connection-refused errors. Re-running the job sometimes helped — a classic race signature. The application logs showed successful boot messages timestamped after the Cypress failures, proving the tests had arrived too early.concurrently "npm run dev" "cypress run" was safe because both processes start together. Nobody realized process-spawn and port-listening are different events, or that a cold CI machine boots the framework far slower than a warm laptop.concurrently. On a cold runner, Vite needed 20-40 seconds to compile and bind port 5173, while Cypress started visiting within 5 seconds. Every cy.visit() hit a closed port and failed with ECONNREFUSED. Local runs passed because the developer's server was already warm from hours of coding.start-server-and-test 'npm run dev' http://localhost:3000 'cypress run', added a curl --fail health-check step before it, and logged the server's bound address on boot. The suite went from 30% red to green on every run that week, and the health check now names the culprit in seconds on the rare occasion the server itself crashes.- Process-started is not port-listening. Gate test runners on the HTTP resource responding, never on the server process spawning.
- A red E2E job should first prove the app is reachable with curl before anyone reads a spec file — most visit failures are infrastructure, not tests.
- Log the server's bound address on every boot. When local, Docker, and CI disagree about hosts and ports, that one log line ends the argument.
curl -v http://localhost:3000/ (your exact visit URL). Connection refused confirms nothing listens there. Then run lsof -i :3000 (macOS/Linux) to see if any process holds the port. If the port is empty, start your dev server (npm run dev) and re-run — do not touch the spec.npx cypress info and open cypress.config.js. Compare the baseUrl value character by character with the address your server log prints on boot (host and port). If they differ, fix baseUrl — or export CYPRESS_BASE_URL to match the server — then confirm with curl before re-running Cypress.& backgrounding or concurrently with no URL check, replace it with start-server-and-test 'npm run dev' http://localhost:3000 'cypress run'. As a one-off proof, run npx wait-on http://localhost:3000 && npx cypress run and watch the flake vanish.printenv | grep -i cypress in the CI job to see the actual value, and echo Cypress.config('baseUrl') from a support file. Set CYPRESS_BASE_URL explicitly in each CI environment's variables (staging jobs get the staging URL). Never rely on a .env file that only exists on your laptop.Local: http://localhost:3000 vs Network: http://172.x.x.x in its boot log. If Cypress runs in a different container, bind with --host 0.0.0.0 and verify Docker's -p 3000:3000 mapping. Add curl --fail $CYPRESS_BASE_URL as a CI step before Cypress so a mapping mistake fails with a clear message.| File | Command / Code | Purpose |
|---|---|---|
| cypress.config.js | const { defineConfig } = require('cypress') | Why cy.visit() Fails When the Dev Server Is Not Running |
| cypress | cy.visit('/') // -> http://localhost:3000/ | baseUrl vs Absolute URLs |
| package.json | { | Wait for Readiness |
| .github | jobs: | CI Failures |
Key takeaways
Common mistakes to avoid
5 patternsRunning Cypress before starting the dev server
cy.visit() fails instantly with ECONNREFUSED, and you waste time re-reading the spec when the app was never up.npm run dev (or your framework's equivalent) in one terminal and confirm the page loads in a real browser before you ever open Cypress. Better yet, encode the ordering in npm scripts with start-server-and-test so the mistake becomes impossible.Hard-coding localhost:3000 in every spec
baseUrl: 'http://localhost:3000' (your real port) in cypress.config.js and call cy.visit('/') with relative paths. If you must use absolute URLs, keep them in one config value so there is a single place to update.Assuming the server is ready because the process started
start-server-and-test 'npm run dev' http://localhost:3000 'cypress run' or npx wait-on http://localhost:3000 before launching Cypress. Process-is-running does not mean port-is-listening.Setting CYPRESS_BASE_URL in one environment and forgetting the rest
CYPRESS_BASE_URL in each environment's CI config (staging URL for staging jobs, preview URL for preview jobs) and assert it at the top of a support file: if (!Cypress.config('baseUrl')) throw new Error('baseUrl is missing').Letting the port or host drift between local, Docker, and CI
vite --host 0.0.0.0 --port 3000, or Docker port mapping -p 3000:3000) and keep one documented port per project. Log the bound address on boot so CI output shows where the server actually listens.Interview Questions on This Topic
Your Cypress run fails on cy.visit('/') with ECONNREFUSED. What is the first thing you check?
curl the URL to confirm, then start the dev server, then re-run. For a permanent fix I would gate Cypress behind start-server-and-test so the ordering is enforced, not remembered.Frequently Asked Questions
20+ years shipping production backend systems. Drawn from code that ran under real load.
That's Cypress. Mark it forged?
6 min read · try the examples if you haven't