How I Built a CLI Tool That Instantly Solves EADDRINUSE Errors
10 min readJust now
–
Every developer has been there. You’re in the zone, iterating fast on your Next.js app. You kill the dev server with Ctrl+C, make some changes, and run npm run dev again. Then you see it:
Press enter or click to view image in full size
Photo by Tim van der Kuip on Unsplash
Error: listen EADDRINUSE: address already in use :::3000
Your port is still occupied by a zombie process. Now you’re stuck Googling “how to kill process on port” for the hundredth time, copying obscure terminal commands, and breaking your flow.
I got tired of this. So I …
How I Built a CLI Tool That Instantly Solves EADDRINUSE Errors
10 min readJust now
–
Every developer has been there. You’re in the zone, iterating fast on your Next.js app. You kill the dev server with Ctrl+C, make some changes, and run npm run dev again. Then you see it:
Press enter or click to view image in full size
Photo by Tim van der Kuip on Unsplash
Error: listen EADDRINUSE: address already in use :::3000
Your port is still occupied by a zombie process. Now you’re stuck Googling “how to kill process on port” for the hundredth time, copying obscure terminal commands, and breaking your flow.
I got tired of this. So I built Port-Nuker — a cross-platform CLI tool that kills port-blocking processes with a single command. But this wasn’t just about solving my own problem. It became a fascinating deep dive into process management, cross-platform system programming, and developer experience design.
In this article, I’ll walk you through the technical challenges, architectural decisions, and implementation details that went into building Port-Nuker.
Table of Contents
- The Problem Space
- Architecture Overview
- Cross-Platform Process Discovery
- Smart Port Detection
- Docker-Aware Process Handling
- Deep Kill: Process Groups & Zombie Processes
- Safety Mechanisms
- Interactive Mode & UX Design
- Lessons Learned
The Problem Space
Before diving into the solution, let’s understand what we’re dealing with.
Why Do Ports Get Stuck?
When you run a development server, it binds to a port (e.g., 3000). When you terminate the process with Ctrl+C, the OS should release that port immediately. But several scenarios can leave ports occupied:
- Graceful Shutdown Delays: The process is still cleaning up resources
- Zombie Processes: Parent process dies, but child processes remain
- TIME_WAIT State: TCP sockets remain in TIME_WAIT for up to 2 minutes
- Improper Signal Handling: Process doesn’t respond to SIGTERM
- Docker Containers: Container holds the port even after the app crashes
The Traditional Solution
On Unix systems, developers typically run:
lsof -i :3000 | grep LISTEN | awk '{print $2}' | xargs kill -9
On Windows:
netstat -ano | findstr :3000taskkill /PID <PID> /F
These commands are:
- Hard to remember (especially the Windows variant)
- Error-prone (easy to kill the wrong process)
- Platform-specific (different commands for different OSes)
- Not developer-friendly (no safety checks or confirmations)
Port-Nuker solves all of these problems with a single, cross-platform command: nuke 3000.
Architecture Overview
Port-Nuker is built as a Node.js CLI tool with zero runtime dependencies (only inquirer for interactive prompts and cli-table3 for formatting). Here’s the high-level architecture:
The tool follows a modular design with clear separation of concerns:
- Argument Parsing: Handles flags (
--wait,--force,--deep) and port specifications - Platform Detection: Determines OS and selects appropriate system commands
- Process Discovery: Finds PIDs using platform-specific tools
- Safety Layer: Validates protected ports and Docker processes
- Execution Layer: Kills processes with proper error handling
- User Interface: Interactive prompts and formatted output
Cross-Platform Process Discovery
The biggest technical challenge was making Port-Nuker work seamlessly across Windows, macOS, and Linux. Each platform has different tools for process inspection.
Windows: netstat + taskkill
On Windows, we use netstat -ano to list all network connections with their PIDs:
function findPidByPort(port, callback) { const platform = os.platform(); if (platform === 'win32') { const cmd = `netstat -ano | findstr :${port}`; exec(cmd, (error, stdout) => { if (error || !stdout) { callback(null); return; } const lines = stdout.trim().split('\n'); for (const line of lines) { const parts = line.trim().split(/\s+/); // Proto Local-Address Foreign-Address State PID // TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345 if (parts.length >= 5) { const localAddress = parts[1]; const state = parts[3]; const pid = parts[4]; // Exact port matching const lastColonIndex = localAddress.lastIndexOf(':'); const portFound = localAddress.substring(lastColonIndex + 1); if (portFound === port && state === 'LISTENING') { callback(pid); return; } } } callback(null); }); }}
Key Implementation Details:
- Exact Port Matching: We use
lastIndexOf(':')to handle IPv6 addresses like[::]:3000 - State Filtering: Only
LISTENINGports are considered (notESTABLISHEDorTIME_WAIT) - Regex-Free Parsing: Using
split(/\s+/)is faster than regex for simple whitespace splitting
Once we have the PID, we kill it with taskkill /F /PID <PID>:
function killPid(pid, callback) { const platform = os.platform(); const killCommand = platform === 'win32' ? `taskkill /F /PID ${pid}` : `kill -9 ${pid}`; exec(killCommand, (error) => { if (error) { console.error(`Failed to kill process: ${error.message}`); if (callback) callback(error); return; } console.log(`Successfully nuked process ${pid}.`); if (callback) callback(null); });}
Unix: lsof + kill
On macOS and Linux, we use lsof (List Open Files):
if (platform !== 'win32') { const cmd = `lsof -i :${port} -t`; exec(cmd, (error, stdout) => { if (error || !stdout) { callback(null); return; } const pid = stdout.trim().split('\n')[0]; callback(pid); });}
The -t flag tells lsof to output only PIDs (terse mode), making parsing trivial. We then use kill -9 to send a SIGKILL signal.
Why Not Use a Node.js Library?
You might wonder: “Why shell out to system commands instead of using a Node.js library like find-process or fkill?"
Reasons:
- Minimal Dependencies: Fewer dependencies = faster installs, smaller attack surface
- Direct Control: We can customize the exact commands and parsing logic
- Performance: Shelling out is actually faster than some JS-based solutions that poll
/proc - Reliability: System tools are battle-tested and guaranteed to exist on target platforms
Smart Port Detection
One of Port-Nuker’s killer features is zero-argument invocation. Just run
nuke in your project directory, and it auto-detects the port from package.json.
Parsing package.json Scripts
Most JavaScript projects define their dev server port in npm scripts:
{ "scripts": { "dev": "next dev -p 3000", "start": "vite --port 4000", "server": "PORT=5000 node server.js" }}
We extract ports using regex patterns:
function extractPortsFromScripts(scripts) { const ports = new Set(); const patterns = [ /-p\s+(\d+)/, // -p 3000 /--port[=\s]+(\d+)/, // --port=3000 or --port 3000 /PORT=(\d+)/, // PORT=3000 /:(\d{4,5})\b/, // :3000 (4-5 digits) ]; for (const script of Object.values(scripts)) { for (const pattern of patterns) { const match = script.match(pattern); if (match) { const port = parseInt(match[1]); if (port >= 1 && port <= 65535) { ports.add(port); } } } } return Array.from(ports);}
Pattern Breakdown:
/-p\s+(\d+)/: Matches Next.js style (next dev -p 3000)/--port[=\s]+(\d+)/: Matches Vite/Webpack style (--port=4000)/PORT=(\d+)/: Matches environment variable style (PORT=5000)- /:(\d{4,5})\b/: Catches bare port numbers in URLs (
http://localhost:3000)
Handling Multiple Ports
If multiple ports are detected, we prompt the user:
async function handleSmartDetection(ports) { if (ports.length === 1) { console.log(`📦 Detected project port: ${ports[0]}`); const answer = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message: `Kill process on port ${ports[0]}?`, default: true }]); if (answer.confirmed) { nukePort(ports[0]); } } else { console.log(`📦 Detected multiple ports: ${ports.join(', ')}`); const answer = await inquirer.prompt([{ type: 'list', name: 'port', message: 'Which port would you like to kill?', choices: [ ...ports.map(p => ({ name: `Port ${p}`, value: p })), { name: 'Cancel', value: null } ] }]); if (answer.port) { nukePort(answer.port); } }}
This creates a seamless UX where developers can just type
nuke and get intelligent defaults.
Docker-Aware Process Handling
One of the most frustrating scenarios is when a port is held by a Docker container. Killing the Docker daemon would stop all containers, which is almost never what you want.
Port-Nuker detects Docker processes and offers to stop just the specific container:
Detecting Docker Processes
function isDockerProcess(commandName) { const dockerProcessNames = [ 'docker', 'dockerd', 'com.docker.backend', 'docker desktop', 'containerd', 'docker.exe' ]; const lowerCommand = commandName.toLowerCase(); return dockerProcessNames.some(name => lowerCommand.includes(name));}
Finding the Specific Container
Once we detect Docker, we search for the container using that port:
async function findDockerContainerByPort(port) { return new Promise((resolve) => { exec('docker ps --format "{{.ID}}|{{.Names}}|{{.Ports}}"', (error, stdout) => { if (error) { resolve(null); return; } const lines = stdout.trim().split('\n'); for (const line of lines) { const [id, name, ports] = line.split('|'); // Match formats: 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp const portRegex = new RegExp(`[:\\s]${port}->|[:\\s]${port}/`); if (ports && portRegex.test(ports)) { resolve({ id, name, ports }); return; } } resolve(null); }); });}
Interactive Container Handling
When a Docker container is found, we present options:
async function handleDockerProcess(port, pid) { console.log(`🐳 Detected Docker process (PID: ${pid})`); const container = await findDockerContainerByPort(port); if (container) { console.log(`📦 Found container: ${container.name}`); const answer = await inquirer.prompt([{ type: 'list', name: 'action', message: 'What would you like to do?', choices: [ { name: `Stop container '${container.name}' (recommended)`, value: 'stop' }, { name: '⚠️ Kill Docker daemon (stops ALL containers)', value: 'kill' }, { name: 'Cancel', value: 'cancel' } ] }]); if (answer.action === 'stop') { exec(`docker stop ${container.id}`, (error) => { if (!error) { console.log(`✅ Successfully stopped container ${container.id}`); } }); } }}
This prevents accidental destruction of your entire Docker environment while still giving you the nuclear option if needed.
Deep Kill: Process Groups & Zombie Processes
Sometimes a parent process spawns children, and when you kill the parent, the children become zombies still holding the port. The --deep flag solves this by killing the entire process group.
Unix: Process Groups (PGID)
On Unix systems, processes belong to process groups identified by a PGID:
function getProcessGroup(pid, callback) { // Get the process group ID exec(`ps -o pgid= -p ${pid}`, (error, stdout) => { if (error) { callback([pid]); return; } const pgid = stdout.trim(); // Get all PIDs in that group exec(`ps -o pid= -g ${pgid}`, (error, stdout) => { if (error) { callback([pid]); return; } const pids = stdout.trim().split('\n').map(p => p.trim()); callback(pids); }); });}
Windows: Recursive Child Discovery
Windows doesn’t have process groups, so we recursively find children using WMIC:
function getWindowsProcessGroup(pid, allPids = []) { allPids.push(pid); const cmd = `wmic process where (ParentProcessId=${pid}) get ProcessId`; exec(cmd, (error, stdout) => { if (error || !stdout) { return allPids; } const lines = stdout.trim().split('\n'); const childPids = []; for (let i = 1; i < lines.length; i++) { const childPid = lines[i].trim(); if (childPid && !isNaN(childPid)) { childPids.push(childPid); } } // Recursively get children of children childPids.forEach(childPid => { getWindowsProcessGroup(childPid, allPids); }); return allPids; });}
Killing the Group
Once we have all PIDs, we kill them in sequence:
function killProcessGroup(pid, platform) { getProcessGroup(pid, platform, (pids) => { console.log(`⚠️ Deep kill mode: Found ${pids.length} process(es)`); console.log(` PIDs: ${pids.join(', ')}\n`); pids.forEach(p => { killPid(p); }); });}
This ensures no zombie processes are left behind.
Safety Mechanisms
Port-Nuker includes several safety features to prevent accidental damage:
1. Protected Ports
Certain ports are protected and require the --force flag:
const SAFE_PORTS = [22, 80, 443, 3306, 5432, 6379, 27017, 5000, 8080];const SAFE_PORT_DESCRIPTIONS = { 22: 'SSH', 80: 'HTTP', 443: 'HTTPS', 3306: 'MySQL', 5432: 'PostgreSQL', 6379: 'Redis', 27017: 'MongoDB', 5000: 'Flask/Docker Registry', 8080: 'Alternative HTTP'};function checkSafePort(port, force) { if (SAFE_PORTS.includes(parseInt(port)) && !force) { const desc = SAFE_PORT_DESCRIPTIONS[port]; console.error(`⚠️ Port ${port} is protected (${desc}).`); console.error(` Use --force to override: nuke ${port} --force`); return false; } return true;}
This prevents you from accidentally killing your SSH server or database.
2. Confirmation Prompts
In interactive mode, we always ask for confirmation:
const confirm = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message: `Kill process ${command} (PID: ${pid})?`, default: false}]);
3. Wait Mode Timeout
The --wait flag polls the port until it’s free, but with a 30-second timeout:
function pollPortUntilFree(port, maxWaitSeconds = 30) { console.log(`Waiting for port ${port} to be released...`); const pollInterval = 500; // ms const maxAttempts = (maxWaitSeconds * 1000) / pollInterval; let attempts = 0; const poll = setInterval(() => { checkPortInUse(port, (inUse) => { attempts++; if (!inUse) { clearInterval(poll); console.log(`✅ Port ${port} is now free!`); process.exit(0); } if (attempts >= maxAttempts) { clearInterval(poll); console.error(`⏱️ Timeout: Port ${port} still in use after ${maxWaitSeconds}s`); process.exit(1); } }); }, pollInterval);}
This prevents infinite loops if the port never gets released.
Interactive Mode & UX Design
The nuke list command provides a beautiful, interactive interface for browsing all active ports:
Port Scanning
function listPorts() { const platform = os.platform(); const listCommand = platform === 'win32' ? 'netstat -ano' : 'lsof -i -P -n'; console.log('Scanning for active ports...\n'); exec(listCommand, (error, stdout) => { const processes = parseProcessList(stdout, platform); displayPortsTable(processes); promptKillProcess(processes); });}
Formatted Table Output
Using cli-table3, we create a beautiful ASCII table:
function displayPortsTable(processes) { const table = new Table({ head: ['Port', 'PID', 'Protocol', 'Command', 'User', 'Memory'], colWidths: [8, 10, 10, 30, 15, 12], style: { head: ['cyan', 'bold'], border: ['gray'] } }); processes.forEach(p => { table.push([ p.port, p.pid, p.protocol, p.command, p.user, p.memory ]); }); console.log(table.toString());}
Arrow Key Selection
Using inquirer, we enable arrow key navigation:
const answer = await inquirer.prompt([{ type: 'list', name: 'pid', message: 'Select a process to kill:', choices: processes.map(p => ({ name: `Port ${p.port} - ${p.command} (PID: ${p.pid})`, value: p.pid })), pageSize: 15}]);
This creates a smooth, IDE-like experience in the terminal.
Lessons Learned
Building Port-Nuker taught me several valuable lessons:
1. Cross-Platform is Hard
Every platform has quirks:
- Windows: IPv6 addresses in
netstatoutput require special parsing - macOS:
lsofsometimes requiressudofor system processes - Linux: Different distros have different versions of
pswith varying flags
Solution: Extensive testing on all three platforms, with fallback logic for edge cases.
2. User Experience Matters for CLI Tools
Initially, Port-Nuker just killed processes with no confirmation. But after accidentally killing my SSH server, I added:
- Confirmation prompts
- Protected port warnings
- Docker-aware handling
- Clear, emoji-enhanced output
Takeaway: Even CLI tools need thoughtful UX design.
3. Zero Dependencies is a Feature
By keeping dependencies minimal (only inquirer and cli-table3), Port-Nuker:
- Installs in <2 seconds
- Has a tiny attack surface
- Works even in restricted environments
Lesson: Resist the urge to add dependencies. Shell out to system tools when possible.
4. Smart Defaults Beat Configuration
Instead of requiring users to specify the port every time, smart detection from
package.json makes the tool feel magical. 80% of the time, nuke with no arguments just works.
5. Error Messages Should Be Actionable
Bad error message:
Error: Port not found
Good error message:
⚠️ Port 22 is protected (SSH). This port is typically used for critical services. Use --force to override: nuke 22 --force
Principle: Every error should tell the user exactly what to do next.
Conclusion
Port-Nuker started as a simple script to solve a personal annoyance, but evolved into a robust, cross-platform tool with thousands of downloads. The journey taught me that great developer tools are 10% solving the problem and 90% handling edge cases gracefully.
Key technical achievements:
- ✅ Cross-platform process management (Windows, macOS, Linux)
- ✅ Smart port detection from package.json
- ✅ Docker-aware container handling
- ✅ Process group killing for zombie processes
- ✅ Interactive mode with beautiful formatting
- ✅ Safety mechanisms for protected ports
- ✅ Wait mode for command chaining
If you’re building CLI tools, remember:
- Prioritize UX — even for terminal apps
- Handle edge cases — Docker, protected ports, zombies
- Provide smart defaults — zero-config when possible
- Make errors actionable — tell users what to do
- Test on all platforms — cross-platform is harder than it looks
Try It Yourself
Install Port-Nuker globally:
npm install -g port-nuker
Or use with npx (no installation):
npx port-nuker 3000
GitHub: alexgutscher26/Port-Nuker
What’s Next?
I’m considering these features for v2.0:
- Port range monitoring:
nuke watch 3000-3005to auto-kill on detection - Config file support:
.nukercfor custom protected ports - Process tree visualization: Show parent-child relationships
- Systemd/launchd integration: Manage services, not just processes
What features would you like to see? Drop a comment below!
If you found this article helpful, please give it a clap 👏 and follow me for more deep dives into developer tooling and systems programming.