als the PID and process name:
LISTEN 0 511 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=12345,fd=18))
Windows:
PowerShell provides structured data for port inspection.
Get-NetTCPConnection -LocalPort 3000 | Select-Object OwningProcess
Docker Environments:
Inside minimal containers, lsof or ss may be missing. Use the /proc filesystem or rely on host-level inspection.
# Inside container (if procfs available)
cat /proc/net/tcp | awk '{print $2}' | grep -i ":0BB8"
Note: 0BB8 is the hex representation of port 3000.
Step 2: Programmatic Binding with Retry Logic
Applications should handle binding failures gracefully rather than crashing silently. Implement a binding wrapper that validates the port and logs context.
import { createServer, Server } from 'http';
import { AddressInfo } from 'net';
const SERVICE_PORT = process.env.SERVICE_PORT
? parseInt(process.env.SERVICE_PORT, 10)
: 3000;
const server: Server = createServer((req, res) => {
res.writeHead(200);
res.end('OK');
});
function bindServer(targetPort: number): Promise<AddressInfo> {
return new Promise((resolve, reject) => {
server.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
reject(
new Error(
`Port ${targetPort} is occupied. ` +
`Check for running instances or configure SERVICE_PORT.`
)
);
} else {
reject(err);
}
});
server.listen(targetPort, () => {
const addr = server.address() as AddressInfo;
resolve(addr);
});
});
}
async function start() {
try {
const addr = await bindServer(SERVICE_PORT);
console.log(`Service listening on ${addr.address}:${addr.port}`);
} catch (err) {
console.error('Startup failed:', err.message);
process.exit(1);
}
}
start();
Rationale:
- Using
once('error') prevents memory leaks from repeated error events.
- Parsing
SERVICE_PORT with a fallback ensures the app works locally but respects environment overrides.
- Rejecting with a descriptive message aids debugging in CI/CD logs.
Step 3: Dynamic Allocation via Port Zero
For internal tools, test runners, or services where the port is irrelevant to external consumers, bind to port 0. The OS assigns a random available port.
const dynamicServer = createServer();
dynamicServer.listen(0, () => {
const port = (dynamicServer.address() as AddressInfo).port;
console.log(`Dynamic service running on port ${port}`);
// Expose port via environment variable or stdout for parent process
process.stdout.write(`PORT=${port}\n`);
});
Rationale:
- Eliminates
EADDRINUSE entirely for the bound service.
- Ideal for integration tests running in parallel.
- Requires the parent process or test runner to capture the stdout or environment variable to know which port to hit.
Step 4: Graceful Shutdown and Connection Draining
Abrupt termination leaves ports in TIME_WAIT or causes dropped connections. Implement a shutdown handler that drains active requests.
const SHUTDOWN_TIMEOUT_MS = 10000;
async function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Initiating graceful shutdown...`);
server.close((err) => {
if (err) {
console.error('Error closing server:', err);
process.exit(1);
}
console.log('HTTP server closed. Exiting.');
process.exit(0);
});
// Force exit if connections do not drain within timeout
setTimeout(() => {
console.error('Shutdown timeout reached. Forcing exit.');
process.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Rationale:
SIGTERM is sent by orchestrators during rolling updates.
server.close() stops accepting new connections but allows existing ones to finish.
- The timeout prevents the process from hanging indefinitely if a client keeps the connection open.
Pitfall Guide
-
The kill -9 Reflex
- Explanation: Using
kill -9 (SIGKILL) forces immediate termination without cleanup. This can leave the port in an inconsistent state or prevent the process from releasing resources.
- Fix: Always attempt
kill <PID> (SIGTERM) first. Only use SIGKILL if the process ignores the termination signal after a reasonable delay.
-
Zombie Processes in Development
- Explanation: Tools like
nodemon or ts-node may spawn child processes that survive when the parent is killed. Closing the terminal does not always kill the background process.
- Fix: Use process group killing or dedicated cleanup scripts. On Linux/macOS:
pkill -f "node.*dev-server".
-
Docker Host-Port Collision
- Explanation: Mapping
3000:3000 in two different docker-compose files or services binds the host port twice. Docker will reject the second container.
- Fix: Use unique host ports for each service (e.g.,
3001:3000) or run services on a shared Docker network without host port mapping, accessing them via service names.
-
Ignoring TIME_WAIT State
- Explanation: After a server closes, the port may remain in
TIME_WAIT for 60 seconds. Attempting to restart immediately can trigger EADDRINUSE even though no process is listening.
- Fix: Node.js sets
SO_REUSEADDR by default, which usually mitigates this. If disabled, re-enable it. For custom socket handling, ensure reuseAddr: true is passed to net.createServer.
-
Race Conditions in CI/CD
- Explanation: Parallel CI jobs may attempt to start services on the same default port, causing flaky test failures.
- Fix: Use dynamic port allocation (
port: 0) for test services or inject unique ports via environment variables per job.
-
IPv6 vs IPv4 Binding Ambiguity
- Explanation: Node.js may bind to
:: (IPv6) which can conflict with IPv4 listeners on some systems, or vice versa. Error messages like :::3000 indicate IPv6 binding.
- Fix: Explicitly specify the address in
server.listen(port, '0.0.0.0') for IPv4 or :: for IPv6 to avoid ambiguity.
-
Hardcoded Ports in Monorepos
- Explanation: Running multiple services locally with hardcoded ports requires manual coordination and leads to frequent conflicts.
- Fix: Centralize port configuration in a shared config package or use environment variables with a
.env file per service.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Production Microservice | Environment Variable | Predictable for load balancers; compatible with Kubernetes/Docker injection. | Low |
| Local Development Tool | Dynamic (Port 0) | Zero configuration; eliminates conflicts when running multiple instances. | Low |
| Integration Tests | Dynamic Allocation | Enables parallel execution without port collisions. | Low |
| Legacy Monolith | Hardcoded with Override | Minimizes refactoring risk; allows env override for specific deployments. | Low |
| Service Mesh Sidecar | Fixed Internal Port | Mesh proxies expect consistent ports for routing and mTLS. | Low |
Configuration Template
.env Example:
# Service Configuration
SERVICE_PORT=3000
SERVICE_HOST=0.0.0.0
# Database Ports (Example)
DB_PORT=5432
REDIS_PORT=6379
docker-compose.yml Snippet:
version: '3.8'
services:
api-gateway:
build: ./gateway
ports:
- "8080:3000" # Host:Container mapping
environment:
- SERVICE_PORT=3000
depends_on:
- user-service
user-service:
build: ./user-service
ports:
- "8081:3000" # Unique host port
environment:
- SERVICE_PORT=3000
package.json Scripts:
{
"scripts": {
"start": "node dist/index.js",
"dev": "SERVICE_PORT=3001 nodemon src/index.ts",
"test:integration": "jest --runInBand --detectOpenHandles",
"kill-ports": "lsof -ti:3000,3001 | xargs kill -SIGTERM || true"
}
}
Quick Start Guide
- Identify the Conflict: Run
lsof -i :3000 or ss -tlnp | grep :3000 to find the PID occupying the port.
- Terminate Safely: If the process is safe to kill, run
kill <PID>. Verify with lsof again.
- Update Configuration: Modify your application to read the port from
process.env.SERVICE_PORT with a sensible fallback.
- Add Shutdown Handler: Implement the graceful shutdown logic to ensure clean port release on exit.
- Restart Service: Start the application. If using Docker, ensure host ports are unique in your compose file.