What you'll learn
Quick Answer
A 500 Internal Server Error means an unhandled exception reached the top of your application. The response is deliberately vague for security, so the actual cause is only in your server logs — that is always the first place to look. Distinguish it from 502, which means the application is not responding at all, and 504, which means it is too slow. The frequent causes are database connection failures, missing environment variables, unhandled promise rejections, permissions and out-of-memory conditions.
What 500 Means, and What It Does Not
500 is the catch-all: something threw and nothing caught it. The server generates a generic response because leaking a stack trace to the public would hand an attacker your framework versions, file paths and sometimes credentials.
So the browser deliberately tells you nothing useful. The information exists only in your logs.
First, separate 500 from its neighbours, because they point at different layers:
- 500 — your application ran and threw. Look at application logs.
- 502 Bad Gateway — the proxy could not get a valid response. Usually the app crashed or is not listening on the expected port.
- 503 Service Unavailable — overloaded or deliberately in maintenance.
- 504 Gateway Timeout — the app is alive but did not answer in time. Look for slow queries or blocking code.
That distinction saves real time: 502 usually means dead, 504 usually means slow, 500 means it ran and failed.
Confirm what you are actually getting rather than trusting the browser's error page:
curl -i https://yoursite.com/api/thing
Find the Logs — Always Step One
Everything else is guessing until you have read the stack trace.
# Node with PM2
pm2 logs
pm2 logs --err --lines 200
# systemd service
journalctl -u yourapp -n 200 --no-pager
# Docker
docker logs --tail 200 <container>
# nginx, when the app itself logs nothing
tail -n 100 /var/log/nginx/error.log
# Shared hosting / cPanel PHP
tail -n 100 ~/logs/error_logOn PHP shared hosting, errors are usually hidden from the response by default. Enable display temporarily while debugging only, or better, read the error log directly.
If the logs show nothing at all, that is itself informative: the request may not be reaching your application. Check that the process is running, listening on the expected port, and that the proxy is configured to reach it.
The other common cause of empty logs is a crash during startup — the app dies before its logger initialises. Try running it in the foreground on the server to see the output directly.
Reproduce It Deliberately
An error you can trigger on demand is nearly solved. One that appears occasionally is far harder, so invest in reproducing it.
Narrow the trigger. Does it fail for every request or only some? A specific user, a specific record, a specific input? Intermittent failures usually mean data-dependent code paths, race conditions, or resource exhaustion under load.
Check whether it is data-specific. A record with a null field, an unusually long string, or a non-ASCII name will break code that never handled those cases. Try the exact request that failed rather than a similar one.
Compare environments. Working locally and failing in production usually points at one of a short list: a missing environment variable, a different Node or PHP version, missing dependencies because devDependencies were skipped, file permissions, or case-sensitive paths on Linux that Windows forgave.
# Reproduce the exact request
curl -i -X POST https://yoursite.com/api/users \
-H 'Content-Type: application/json' \
-d '{"name":"test"}'Once it reproduces reliably, you can bisect: comment out parts, add logging, and narrow to the failing line.
The Five Usual Causes
Database connection failure. Wrong credentials, the database not running, a connection limit reached, or a firewall between app and database. Shared hosting often has a low connection cap, so a pool that never releases connections produces 500s only under load.
Missing environment variables. The classic deployment failure — it works locally because your .env exists, and fails in production because it was never set there. Fail loudly at startup rather than at request time:
const required = ['DATABASE_URL', 'JWT_SECRET'];
for (const k of required) {
if (!process.env[k]) throw new Error(`Missing env var: ${k}`);
}Unhandled promise rejections. An async route handler that throws without a catch bypasses many error handlers entirely, producing a 500 with a confusing or empty trace. Wrap async handlers, or use a framework version that forwards rejections properly.
File permissions. Uploads, cache directories and log files that the web server user cannot write to. Common after copying files as root.
Out of memory. The process is killed mid-request, so the proxy reports a failure. Look for the OOM killer in system logs, and for code loading an entire large file or query result into memory at once.
Making the Next One Easier
Most of the pain of a 500 is the absence of information. Fix that once and every future incident is shorter.
- Log the stack trace, with context. Include the request path, method, user id and a request id. An error message without context tells you what broke but not for whom.
- Never return the stack trace to the client. Return a generic message plus a reference id, and log the detail against that id. The user can quote the id in a support message.
- Add a global error handler so nothing escapes unlogged. In Express that is the four-argument middleware registered last.
- Validate configuration at startup. Crashing immediately with "Missing env var: JWT_SECRET" is far better than serving 500s for hours.
- Use error monitoring. A service that captures exceptions with stack traces and request context turns "a user says it broke yesterday" into a specific line of code. Free tiers are adequate for small projects.
- Add a health endpoint that checks the database and other dependencies, so you can tell instantly whether the app itself or something it depends on is down.
The underlying discipline: an error you cannot reproduce and cannot see is not debuggable. Everything above exists to make sure the next failure arrives with its own explanation attached.
