❯ cat /blog/zombie-on-port-3000.md
· 4 min read · forensics · node.js · local-dev · debugging
The zombie on port 3000: a restart that never happened
A green build, a freshly 'restarted' server, and every route answering 404. Nobody had broken anything — we were just interrogating a corpse. A short forensic on orphaned processes, and why 'I restarted it' is a claim that needs evidence.
symptom
Local production server, minutes after a clean build: every route — pages that existed, pages that didn't, even image routes — answered 404. The server log repeated one line, unhelpfully:
✓ Ready in 154ms Error: Internal: NoFallbackError Error: Internal: NoFallbackError Error: Internal: NoFallbackError
The same commit was serving production flawlessly at that exact moment. So the code was innocent, the build was innocent, and yet localhost was a wall of 404s. When the same artifact behaves differently in two places, stop reading the artifact and start reading the processes.
evidence
- The server had been "restarted" moments earlier — its supervisor had even reported the old one as dead.
- Between the old start and the failure, the
.nextbuild directory had been deleted and rebuilt twice — while something was still holding it. - The smoking gun arrived when the "new" server's log was finally read end to end:
Error: listen EADDRINUSE: address already in use :::3000 code: 'EADDRINUSE', syscall: 'listen', port: 3000
root cause
The restart never happened. The old Node process had outlived its supervisor — the task wrapper died, the process didn't — and kept squatting on port 3000. Every "new" server since then was born, found the port taken, printed EADDRINUSE into a log nobody read, and exited. Meanwhile the squatter had had its .next directory rebuilt underneath it twice: its in-memory route manifests pointed at files that no longer existed, which Next surfaces as NoFallbackError and a 404 for absolutely everything.
A zombie: dead to its supervisor, alive to the operating system, and answering requests with the routing table of a build that had been deleted half an hour earlier.
the crime scene, reenacted
Words only carry so far. Below is the incident itself, undead and interactive: try to restart the server and watch the lie happen in real time. The zombie only goes down one way.
intervention
Ask the OS — the only witness that doesn't lie about processes — who actually owns the port, kill that PID, and start clean:
Get-NetTCPConnection -LocalPort 3000 -State Listen | Select -Expand OwningProcess | Stop-Process -Force npm run start # binds, serves 200s
lessons
- "Restarted" is a claim, not a fact.The proof is the port owner's PID changing — not the supervisor's report, not the absence of an error you didn't scroll down to see.
- Never rebuild artifacts underneath a live process. A server reading
.nextwhile you replace it enters a state nobody tested: not the old build, not the new one, but a chimera of both. - Supervisors and task managers track their children, not the truth. Processes get orphaned; the OS port table is the source of record.
- This is the quiet cousin of the bug that worked: the zombie didn't crash, didn't scream, didn't die. It just kept politely answering 404 — and the error that explained everything was sitting unread in the log of a process that lived for two seconds.
The most dangerous line in a log is the one printed by a process you believe is running — but was written by one that already failed to start.