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_log

On 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.

Frequently Asked Questions

What is the difference between 500 and 502? A 500 means your application ran and threw an unhandled exception, so look in the application logs. A 502 means a proxy could not get a valid response at all, usually because the app crashed or is not listening on the expected port.
Why does the browser show no details for a 500 error? Deliberately — returning a stack trace would expose framework versions, file paths and sometimes credentials to anyone. The detail belongs in your server logs, which is always the first place to look.
Why does my app work locally but return 500 in production? The usual suspects are a missing environment variable, a different runtime version, dependencies skipped because they were devDependencies, file permissions, and case-sensitive paths on Linux that Windows tolerated.
How do I find the logs on shared hosting? Look for an error_log file in your account, often under a logs directory or in the same folder as the script. cPanel also exposes recent errors in its interface. Avoid enabling public error display beyond a short debugging session.
What causes intermittent 500 errors? Usually resource limits or data-specific code paths — a connection pool exhausting under load, memory limits, or a record with an unexpected null or unusually long value. Reproduce with the exact failing request rather than a similar one.