Express is popular because it is small and flexible. That same flexibility means small misconfigurations or code paths can produce hard-to-explain behavior under load or after a seemingly harmless change. This guide gives you a practical path to quickly isolate issues, verify fixes, and recover safely.
It focuses on what you can observe and change with minimal risk: versions, environment variables, port usage, logs, JSON parsing, CORS, error handling, and typical data layer failures (for example, MongoDB). Each step includes commands or configuration examples only where they directly apply.
Constructed examples are clearly labeled as such. Replace sample ports, environment names, and paths with your own.
Intro
Troubleshooting works best when it is visible and scoped. Start by inventorying what you are actually running, then adopt a minimal, safe configuration to remove blind spots (logging, error handling, health endpoints). Verify the basics locally before you expand the investigation. Keep recovery steps reversible and easy to validate.
Version and Environment Inventory
Before changing anything, record exactly what is running. This avoids arguing with ghosts caused by mismatched versions or environment variables.
- Record runtime versions
- Node.js version:
node -v
- npm version:
npm -v
- Local Express version used by the project:
npm list express
If the result shows multiple versions in a monorepo, record the one under the API service path.
- Capture OS and port usage
- Current user and OS basics:
uname -a # macOS/Linux
ver # Windows Command Prompt
- Port occupancy for a typical Express port (replace 3000):
lsof -i :3000 -P -n # macOS/Linux
netstat -anp | grep :3000 # Linux alternative
netstat -ano | findstr :3000 # Windows
If another process is bound to the port, you will see its PID and can decide whether to stop it or use a different port for the API.
- Snapshot environment variables that affect the API
Typical variables include PORT, NODE_ENV, and database connection settings.
# macOS/Linux
printenv | grep -E '^(PORT|NODE_ENV|MONGODB_|DB_|JWT_|API_)'
# Windows PowerShell
Get-ChildItem Env: | Where-Object { $_.Name -match '^(PORT|NODE_ENV|MONGODB_|DB_|JWT_|API_)' }
Record the output in your incident notes. Small mismatches, like a missing PORT or a different NODE_ENV, explain many surprises.
- Inspect package.json and start command
Open package.json and note:
- scripts.start and scripts.dev
- engines (if present)
- express, body-parser (if any), cors, logging libraries (morgan, pino, winston)
These determine how the service actually starts and what middleware is in play.
Safe Configuration Path
Adopt small, reversible changes that improve observability and safety without altering business logic.
- Minimal logging and error handling
Add request logging with morgan (development-friendly) and a centralized error handler. This example is constructed.
// app.js (constructed example)
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const app = express();
// Parse JSON with a sane limit; surface syntax errors as 400
app.use(express.json({ limit: '1mb' }));
app.use((err, req, res, next) => {
if (err && err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'Invalid JSON body' });
}
return next(err);
});
// Request logging
app.use(morgan('combined'));
// CORS: start narrow and explicit
app.use(cors({ origin: ['https://your-frontend.example.com'], methods: ['GET','POST','PUT','DELETE','OPTIONS'], credentials: true }));
// Health and info endpoints
app.get('/healthz', (req, res) => res.status(200).json({ status: 'ok' }));
app.get('/info', (req, res) => res.json({ name: 'example-api', version: process.env.BUILD_SHA || 'dev' }));
// Example route
app.get('/api/v1/ping', (req, res) => res.json({ pong: true }));
// Centralized error handler
app.use((err, req, res, next) => {
console.error('Unhandled error', { message: err.message, stack: err.stack });
if (res.headersSent) return next(err);
res.status(500).json({ error: 'Internal Server Error' });
});
module.exports = app;
- A clear server entry with timeouts
Set server-level timeouts to avoid stalls and log when the server starts. Adjust values to match your upstream time budgets.
// server.js (constructed example)
const http = require('http');
const app = require('./app');
const PORT = Number(process.env.PORT || 3000);
const server = http.createServer(app);
// Avoid requests hanging forever
server.requestTimeout = 60_000; // 60s
server.headersTimeout = 65_000; // 65s
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT} env=${process.env.NODE_ENV || 'development'}`);
});
- Graceful process-level handlers
Do not mask problems; log and exit on fatal signals so your process manager can restart.
// index.js (constructed example)
require('dotenv').config();
require('./server');
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection', reason);
process.exit(1);
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception', err);
process.exit(1);
});
- Keep CORS explicit
Avoid wildcard origins during troubleshooting. If you must allow multiple origins, list them explicitly so you can see which one is failing.
- Health endpoints
Return simple JSON and HTTP 200 only when core dependencies are reachable. For example, if the database is down, return 503 with a clear body.
// health.js (constructed example)
app.get('/healthz', async (req, res) => {
const checks = { db: 'down' };
try {
// Pseudocode: ping your DB here
// await db.admin().command({ ping: 1 });
checks.db = 'up';
} catch (e) {
return res.status(503).json({ status: 'degraded', checks });
}
res.json({ status: 'ok', checks });
});
Verification and Diagnostics
With safe defaults in place, prove the basics work and gather signals for later debugging.
- Start the service and confirm the port
node index.js
# Expected: Server listening on port 3000 env=development
If nothing prints, confirm you are running the expected file and that console.log has not been suppressed.
- Basic endpoint checks
- Health:
curl -i http://localhost:3000/healthz
# Expected: HTTP/1.1 200 OK and a small JSON body
- Info:
curl -i http://localhost:3000/info
# Expected: 200 with name and version fields
- Sample route:
curl -i http://localhost:3000/api/v1/ping
# Expected: 200 with {"pong":true}
- JSON parsing verification
- Valid JSON:
curl -i -X POST http://localhost:3000/api/v1/echo \
-H 'Content-Type: application/json' \
-d '{"hello":"world"}'
# Expected: 200 with echoed JSON (constructed example route)
- Invalid JSON (constructed example):
curl -i -X POST http://localhost:3000/api/v1/echo \
-H 'Content-Type: application/json' \
-d '{"hello":"world"' # missing closing brace
# Expected: 400 with {"error":"Invalid JSON body"}
If you see a stack trace instead of a 400, confirm your JSON parse error middleware runs before other handlers.
- CORS verification (from command line)
curl -I http://localhost:3000/api/v1/ping \
-H 'Origin: https://your-frontend.example.com'
# Expected: Access-Control-Allow-Origin: https://your-frontend.example.com
If the header is missing, review the cors() configuration and confirm the route is under app.use(cors(...)) or that you apply CORS at the router level.
- Port and process checks
If binding fails with EADDRINUSE, find the process using the port and decide whether to terminate it or change PORT.
lsof -i :3000 -P -n # macOS/Linux
netstat -ano | findstr :3000 # Windows
- Database connectivity check
Surface connectivity in /healthz. For a manual check (constructed example with a Mongo-like command):
# Hypothetical shell check, replace with your DB tool
mongo --eval 'db.adminCommand({ ping: 1 })'
# Expected: ok: 1
If your Express logs show ECONNREFUSED or ETIMEDOUT to the database, verify host, port, credentials, and any network ACLs.
Failure Modes and Recovery
The table below maps common Express API symptoms to likely causes and safe first actions.
| Symptom or log snippet | Likely cause | First checks | Safe fix |
|---|---|---|---|
| EADDRINUSE :3000 on startup | Port already in use | lsof/netstat for port 3000 | Change PORT or stop conflicting process |
| ECONNREFUSED to DB host | Database down or wrong host/port | Ping DB host and port | Correct config or restart DB; return 503 in healthz |
| Unexpected token in JSON at position X | Malformed client JSON | Inspect request body and Content-Type | Catch parse error, return 400 |
| Cannot set headers after they are sent | Double response send | Search for multiple res.send/res.json/res.end | Ensure one code path responds; add return statements |
| socket hang up or ETIMEDOUT | Upstream or server timeout | Check server.requestTimeout; latency in logs | Tune timeouts; improve handler performance |
| 404 for known route | Wrong mount path or prefix | Confirm app.use('/api', router) vs requests | Align route and client path |
| CORS error in browser console | Origin not allowed | Inspect Access-Control-Allow-Origin | Add explicit origin or adjust CORS config |
Scenario A: Port conflict (EADDRINUSE)
- Confirm the conflict
lsof -i :3000 -P -n
- Containment
- If the other process is yours (for example, an old dev server), stop it using its normal stop command.
- If it is not yours, do not kill it blindly. Change your API PORT to a free one and continue troubleshooting.
- Safe fix
- Set PORT to a free value and restart:
PORT=3001 node index.js
- Verification
- Access http://localhost:3001/healthz and confirm 200.
- Rollback
- Revert PORT to the original once the conflicting process is no longer using it.
Scenario B: Crashes on unhandled promise rejections
Symptoms: process exits unexpectedly, logs show UnhandledPromiseRejectionWarning or similar.
- Containment
- Add a top-level unhandledRejection handler that logs and exits (shown earlier). Exiting avoids a corrupted process continuing to serve traffic.
- Root cause
- Find async handlers that throw without passing errors to next(err) or without try/catch.
Constructed example fix:
// Before
app.get('/api/v1/user', async (req, res) => {
const user = await loadUser(req.query.id); // throws -> crash path
res.json(user);
});
// After
app.get('/api/v1/user', async (req, res, next) => {
try {
const user = await loadUser(req.query.id);
res.json(user);
} catch (err) {
next(err);
}
});
- Verification
- Reproduce the failing call and expect a 500 with the centralized error response, not a crash.
- Rollback
- If a recent change introduced the issue, revert that route handler file to the last known good commit.
Scenario C: JSON parse failures flooding logs
Symptoms: 500s and stack traces for invalid JSON bodies.
- Containment
- Ensure express.json middleware is installed before routes and include the parse error handler from Safe Configuration Path.
- Root cause
- Clients sending malformed JSON or wrong Content-Type.
- Safe fix
- Return 400 for parse errors and include a short message.
- Optionally tighten Content-Type check:
app.use((req, res, next) => {
if (['POST','PUT','PATCH'].includes(req.method)) {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Expected application/json' });
}
}
next();
});
- Verification
- Send valid and invalid JSON requests. Expect 200 for valid, 400 or 415 for invalid.
- Rollback
- If clients depend on looser behavior, temporarily remove the Content-Type check but keep the parse error handler.
Scenario D: Database outage or misconfiguration
Symptoms: 500 errors on DB-backed routes; health endpoint shows degraded.
- Containment
- Short-circuit requests when the DB is down to avoid long timeouts. Return 503 with Retry-After.
Constructed example guard:
let dbIsUp = false;
// Update dbIsUp in a small interval or connection events
setInterval(async () => {
try {
// await db.admin().command({ ping: 1 });
dbIsUp = true;
} catch {
dbIsUp = false;
}
}, 5000);
app.use((req, res, next) => {
if (!dbIsUp && req.path.startsWith('/api/v1/orders')) {
res.set('Retry-After', '30');
return res.status(503).json({ error: 'DB unavailable' });
}
next();
});
- Root cause
- Wrong host/port, credentials rotated, firewall rules, or the database process crashed.
- Safe fix
- Correct the connection string and credentials.
- Add a connection timeout and a limited retry policy so your app fails fast instead of hanging:
// Pseudocode: when creating DB client
const client = new DBClient({ connectTimeoutMS: 5000 });
- Verification
- healthz should switch from 503 to 200 within one or two intervals after the DB recovers.
- Business routes should respond within normal latency budgets.
- Rollback
- If a schema or driver upgrade caused the issue, revert the package version and configuration to last known good.
Scenario E: High memory or CPU usage over time
Symptoms: latency increases, process restarts, or out-of-memory errors.
- Containment
- Reduce traffic if possible, or temporarily scale out using multiple instances behind a load balancer where available. If not, restart the process during a maintenance window to restore service while you investigate.
- Root cause
- Leaks from global arrays, setInterval without clearInterval, or repeatedly attaching event listeners.
Constructed example of a leak and fix:
// Leaky: pushes into a global array on every request
const cache = [];
app.get('/api/v1/items', (req, res) => {
cache.push(Date.now());
res.json({ ok: true });
});
// Fixed: use a bounded cache
const cache2 = [];
app.get('/api/v1/items', (req, res) => {
if (cache2.length > 1000) cache2.shift();
cache2.push(Date.now());
res.json({ ok: true });
});
- Safe diagnostics
- Log memory every minute during investigation:
setInterval(() => {
const m = process.memoryUsage();
console.log(`rss=${m.rss} heapUsed=${m.heapUsed}`);
}, 60_000);
- Verification
- After the fix, the heapUsed should stabilize during a steady load period.
- Rollback
- Revert the feature suspected to introduce the leak; confirm memory returns to baseline values (hypothetical example: rss ~ 120MB under steady load).
Operations Checklist
Use this checklist during development and incidents. Keep it short and objective.
| Task | When | How to verify |
|---|---|---|
| Record Node, npm, Express versions | Start of work or incident | node -v, npm -v, npm list express |
| Confirm port binding | On startup or after EADDRINUSE | lsof/netstat shows single listener |
| Verify health and info endpoints | After each deploy or fix | /healthz returns 200 when dependencies are up |
| Check JSON error handling | After parser config changes | Invalid JSON returns 400, not 500 |
| Validate CORS | When front-end origin changes | Response has correct Access-Control-Allow-Origin |
| Observe timeouts | After adding slow logic | Long requests end within requestTimeout |
| Test DB connectivity | Before enabling DB-heavy routes | healthz reports db: up |
| Monitor memory and CPU | During investigation | rss and heapUsed stable over time |
Expected Results and What Good Looks Like
- Startup prints one clear line with port and environment, and the process remains stable under light traffic.
- /healthz returns 200 only when core dependencies are up; otherwise, 503 with a simple explanation.
- Invalid JSON bodies yield 400 with a concise message; valid bodies succeed.
- CORS responses include the exact allowed origin for relevant routes.
- Timeouts are finite, visible in logs, and aligned with your upstream limits.
Failure Verification and Recovery Workflow
Use this repeatable sequence:
- Identify
- Capture the exact error message and timestamp from logs.
- Note the route, method, and any correlation or request ID.
- Contain
- Prefer configuration toggles and guard clauses (return 503) over code rewrites while you learn more.
- If a single route is failing, isolate it behind a feature flag or route-level guard.
- Diagnose
- Reproduce locally with curl using the same method, headers, and body.
- Compare behavior across environments by checking NODE_ENV and PORT differences.
- Fix
- Apply the smallest change that addresses the root cause (for example, add try/catch, adjust CORS origin, correct DB host).
- Verify
- Confirm with both a success case and a representative failure case.
- Watch logs for 5-10 minutes to ensure no recurring errors (hypothetical observation window).
- Roll back if needed
- If the change increases error rates, revert to the last known good version of the specific file or configuration.
Practical Tips
- Keep error messages brief for clients but log full details server-side.
- Prefer explicit lists (CORS origins, routes) over wildcards while investigating.
- Set conservative JSON limits and body sizes, then relax as needed with evidence.
- When in doubt about async errors, wrap handlers and always call next(err) on failure.
- Encode time budgets: requestTimeout and upstream client timeouts should be consistent.
Conclusion
Solid Express troubleshooting starts with visibility: know your versions, ports, and environment variables, and add minimal logging and error handling. Verify basic routes, JSON parsing, CORS, and health checks before diving into deeper changes. Common failures like port conflicts, unhandled rejections, parse errors, and database outages all have safe first steps and clear verification signals.
Start with a narrow, measurable pilot locally, then expand your checks. Use the checklist to keep each incident focused and repeatable. With these practices in place, you will spend less time guessing and more time delivering a stable API.