Wait until a local server is ready before running tests

By

Wait in a script until a local server returns HTTP 200, or until the port is free, with the ports command from Port Pilot.

The test run starts, the server is still booting, and the first request fails. Or the server is listening and already returning an error, and the tests hit that error because “the port is open” was the only check. You want the script to pause until the server is actually ready, then run the suite.

Port Pilot’s ports command can wait. ports wait 4321 --http 200 returns when that port’s HTTP status is 200. ports wait 4321 --free returns when nothing is listening. A test script can sit on either one.

Finding who owns a port, and stopping the wrong process, are the other two tutorials: what’s using a port on macOS and address already in use. A port is the number the server bound. This page only waits on it.

What you need

The ports command runs on macOS and Linux. You need Node.js 20 or later, and npm. The app page calls Node.js 24 a suitable LTS. Scanning uses lsof on macOS, and ss or lsof on Linux. Docker is optional. Nothing is sent off the machine. There is no account.

After you buy it, download the source zip from your Polar receipt and unzip it. From that folder:

npm install
npm run verify
npm link

The executable is ports. Run it with no arguments when you want the terminal dashboard instead of a one-shot wait:

ports

The dashboard refreshes on its own. You can ignore it for the wait itself. It is useful once, before you write the script, so you can see the port and the HTTP column. The menu bar app is optional. Its source builds on macOS 13 or later. The wait commands below are the CLI. Setup details are on the Port Pilot docs.

A preflight that saves a confused script: ports has to be on your PATH in the same environment CI or the agent will use. A “command not found” there is the link, not a server that refused to start.

See whether the port is taken

Waiting is the wrong first question if you don’t know what is already there. ports check prints the listener. ports is-free is the yes-or-no form a script can branch on. Skip both and a wait for HTTP 200 can sit there while a different project holds the port. You then test against that other project and call your own server broken. The check is how you see the project path before the wait starts.

The docs say is-free exits 0 when the port is free and 1 when it is occupied:

ports is-free 4321

Your shell’s $? is that status. The docs are the source of these two codes:

0    the port is free
1    the port is occupied

Exit 1 means do not start a second server on 4321. Exit 0 means the number is free right now. Something else can still bind it before your server does. The wait comes after your server’s process has been started, not instead of starting it.

The readable check is the same port with the process attached:

ports check 4321

The docs say the result can include the PID, project directory, command, parent process, and HTTP status. Docker details appear when a container published the port. The app page doesn’t paste that text. Its check screenshot is a fictional Next.js server on port 3000, and the page says the projects in the demo are fictional. The fields are the ones a real check can show:

Port 3000 is in use
PID:             13382
Command:         next dev --port 3000
Project:         storefront
Framework:       Next.js
Directory:       /Users/admin/Projects/storefront
Chain:           npm run dev → next dev --port 3000
HTTP:            200 http://localhost:3000/ (23ms)
Title:           Acme Ceramics, Handmade mugs and bowls

I trimmed spacing and left out user, CPU, memory, and uptime from that screenshot. The line that matters for a test is HTTP and the status code. 200 with a title means the server answered a page. The last line of the screenshot tells you how to stop it (ports kill 3000). Don’t do that from a test script. Stopping is the other tutorial, and it asks you to confirm.

ports check 4321 --json is the same details for a script. The docs don’t print a sample object. Treat it as those fields in a form you can parse, then read your own output once before you depend on a key name.

Wait until the server returns 200

A listener is not a healthy server. A process can own the port and answer 503, or still be compiling, while your tests already fire. The wait you want, before tests that request pages, is the HTTP status the docs show: 200. A missing HTTP line and a line with some other status are different, and ports check is what separates them.

Example 1: Astro on 4321, then the test command

Start the app the way you usually do, in the project that listens on 4321. In the script that runs tests, don’t launch the suite on the next line. Wait first. The docs’ command, with a 60-second limit, is:

ports wait 4321 --http 200 --timeout 60

--http 200 is the status you require. --timeout 60 is the limit on that wait, in the docs’ example. The docs don’t print what the command writes, and they don’t print the exit code it uses when the 60 seconds run out. They do say the commands return meaningful exit codes. After the wait, ask for the readable check again:

ports check 4321

You want an HTTP line with 200, in the shape of the port 3000 screenshot, for whatever project is actually on 4321. The dashboard screenshot on the app page shows a fictional Astro app, docs-site, on 4321 with HTTP 200. That is the picture of “ready” for this example. Then run the tests.

npm test

If ports check still shows a status other than 200, the wait did not get the condition you asked for. Don’t start the suite. Read the project and command on that check. If they aren’t your app, you waited on someone else’s server. The find-the-port tutorial is how you sort that out. This wait does not stop them.

Wait for HTTP 200 before testsStart the server, then ports wait with --http 200 and a timeout. If the status is 200, run the tests. If the listener returns something else, the wait is not done. ports wait --free is the other wait, for an empty port.YesNot yetStart the serverports wait --http200Status is 200?npm testStill inside thetimeoutNeed the port empty?ports wait --free

Hand a script the rows, not the dashboard

The colored dashboard is for a person at the keyboard. A test script that scrapes it will break the day the columns move. The docs give ports list --json for structured records, and ports check 4321 --json for one port. They don’t print a sample object, so the shape is the same fields as the readable check: port, project, command, HTTP status, and the Docker details when a container published the port. Run the JSON form once, read the keys you actually got, and then have the script read those keys.

ports list --json

ports watch --json is a different tool. It emits a snapshot and then service lifecycle events, and it stays running. A test that should start, wait, and exit wants ports wait, which is the command meant to return. Leave watch for a process that has to hear starts and stops over time. The app page says an agent should not have to scrape a colored table or guess whether a server is ready. The wait plus a check is that, with the exit status your script already knows how to read from is-free.

The dashboard screenshot is still the picture of several answers at once. The projects are fictional. Two of the rows are the contrast a single wait can miss:

4321  docs-site         Astro     HTTP 200
8080  webhooks-worker   Node.js   HTTP 503

Same scan, two ports. A script that waits on 4321 for status 200 is waiting on the Astro row. A script that waits on 8080 for status 200 is waiting on a worker the screenshot shows as 503.

Wait until the port is free instead

Sometimes the test process is the server. It has to bind 4321 itself. Waiting for HTTP 200 cannot succeed yet, because nothing is serving. You wait until the old listener is gone, then your test binds the port. Stop the old process with the other tutorial once you have read its project path. This wait only notices that the port opened up.

Example 2: ports wait —free

This is the other documented form. It does not ask for an HTTP status.

ports wait 4321 --free

The docs show --timeout 60 on the HTTP form, and they show --free on its own line. Leave --free as that line. It returns when the listener on that port is gone. It does not stop the process. If ports is-free 4321 already exits 0, the port is free before you wait. If it exits 1, something is still bound, and --free is how the script pauses until that changes.

The fictional check on port 3000 is in use by next dev. A --free wait on 3000 would sit until that Next.js process is no longer listening. How you stop it, after you have read the project path, is the address-already-in-use tutorial. A script that only waits will wait for the whole timeout you didn’t pass, or until something else quits the process. Don’t point --free at a server you still need.

After it returns, ports is-free 4321 should exit 0. Start your test server then. A gap remains between the free check and the bind. The startup log of your server is the proof it got the port.

Run the same wait again

A second ports wait 4321 --http 200 --timeout 60 looks at the listener that is there now. If the server is already returning 200, the condition is already true. If a reload left you on a different status, run the wait again. It is a new check of the port, using the same flags.

ports check between the two is how you see that status without guessing. The menu bar app can remember the last successful scan and show it while a refresh runs. ports check and ports wait don’t. They read the machine at the moment you run them.

Nothing in this wait needs --yes, and nothing here should pass it. --yes belongs to stop, and the docs say automation must pass it explicitly, and that JSON output is not permission to stop a process. A test harness that waits should not include ports kill. Undo, if you started a server only for the suite, is stopping that server yourself once the tests finish. The wait commands don’t start it and don’t kill it.

Aliases live in your user config, outside the repo. ports alias api 8787 lets later commands say api instead of 8787. Handy for a port you wait on every day. ports alias --remove api forgets the name. It does not free the port. The docs don’t say a wait is faster the second time. Leave the timeout at the documented 60 seconds unless you have a reason to edit that flag on the HTTP form.

If something looks wrong

Example 3: the port is open and the status is 503

The dashboard screenshot shows six fictional dev servers. One row is webhooks-worker on port 8080, framework Node.js, HTTP 503. The process is up. A wait for HTTP 200 on 8080 is waiting for a status that screenshot does not show. Tests that only checked “something listens” would run against the 503.

ports check 8080

Read the HTTP line. If it isn’t 200, fix the worker, or wait on a different status only if you change --http to the code you actually expect. The documented ready check in this tutorial is 200. A 503 is a failed health check, which is also what the app is for. The page’s other rows on that dashboard, including orders-api on 8787, show 200. Same machine, different ports, different answers. Wait on the port your tests call.

If ports isn’t found, rerun npm link from the unzipped source and confirm the shell that runs tests sees it.

If ports is-free exits 1 and ports list doesn’t show the port, the listener may be a system or unknown process. The docs say the default list is development evidence, and ports list --all includes the rest. The wait still sees the occupied port. The list filter is why you couldn’t name the process.

If the HTTP wait hits its limit, ports check the same port. No listener means the server never bound. A listener with a different project means you waited on the wrong number. A listener with a non-200 status means it is up and not healthy.

If the server listens on HTTPS only, or isn’t HTTP, --http 200 can fail its probe while the port is taken. The check is where that shows up. The docs include HTTP status for web services. They don’t say every listener has one.

If --free returns and your server still says the address is in use, something bound the port in between. Check again, then start once. The other tutorial is the one that stops a leftover process after the preview names it.

If you needed Docker’s private port, the check adds container name, image, and private port when Docker published it. Wait on the port your tests dial, which is the one on the machine, not the private port inside the container, unless the tests run in that network.

If a script scrapes the colored dashboard, stop. ports list --json and ports check <port> --json are the structured forms. The dashboard is for you, sitting at the keyboard. The wait is for the script.

Get Port Pilot

Inspect local services, check their health, and stop the right process through a terminal dashboard, CLI, or macOS menu bar app.

See Port Pilot → · Read the docs · $20 including tax, with full source code.

More tutorials for my apps →

Want me to talk about your product? You can sponsor this site.