HTTP 500 Internal Server Error: How to Find the Cause and Fix It
Quick answer
💡A 500 Internal Server Error means the server's code crashed or hit an unhandled condition — the client cannot know the cause from the response alone. The first step is always to check server logs, not the response body. Look for an uncaught exception with a stack trace. In Node.js, unhandledRejection and uncaughtException events log the cause. The stack trace contains the exact file and line number where the crash originated. Fix the code at that location, then add an error handler to prevent future crashes from producing generic 500s.
Error symptoms
- ✕
HTTP response status 500 with body containing error, Internal Server Error, or something went wrong - ✕
Server logs show Unhandled promise rejection or Cannot read properties of undefined - ✕
The request works intermittently — succeeds 9 times out of 10, fails unpredictably on the 10th - ✕
500 errors spike after a deployment and correlate with a specific new code change - ✕
Database errors appear in logs shortly before the 500 response is sent: connection pool timeout or too many connections - ✕
Serverless function logs show Task timed out after N seconds or Memory limit exceeded
Common causes
- •Uncaught exception in a route handler — JavaScript runtime crashes the request and Express sends 500
- •Awaiting a Promise without a try/catch so a rejected promise becomes an unhandled rejection
- •Accessing a property on null or undefined: TypeError: Cannot read properties of null reading 'id'
- •Database connection pool exhaustion: Prisma P2024 or pg too many clients error causes all queries to fail
- •Environment variable missing in production: process.env.DATABASE_URL is undefined and connection setup throws
- •Out-of-memory crash: Node.js heap exhaustion causes the process to terminate and restart mid-request
When it happens
- •Deploying a code change that introduced a null reference or unhandled edge case in a code path
- •Under load when database connection pool is exhausted and new queries cannot acquire a connection
- •First request after a cold start in serverless when initialization code throws and the function crashes
- •When an upstream service returns an unexpected response format and parsing code assumes a different shape
- •After a secret rotation when environment variables still hold the old credentials that no longer authenticate
Examples and fixes
An async route handler that throws without a try/catch, crashing Express and producing a generic 500.
Express route with unhandled async exception
❌ Wrong
const express = require('express');
const { db } = require('./database');
const app = express();
// No try/catch — any thrown error produces 500
app.get('/api/users/:userId', async (req, res) => {
const user = await db.users.findUnique({
where: { id: req.params.userId }
});
// Crashes if user is null — cannot read 'email' of null
res.json({ email: user.email, name: user.name });
});
app.listen(3000);✅ Fixed
const express = require('express');
const { db } = require('./database');
const app = express();
app.get('/api/users/:userId', async (req, res, next) => {
try {
const user = await db.users.findUnique({
where: { id: req.params.userId }
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ email: user.email, name: user.name });
} catch (err) {
next(err); // Pass to centralized error handler
}
});
// Centralized error handler — must have 4 parameters
app.use((err, req, res, next) => {
console.error('Route error:', err.message, err.stack);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(3000);In Express, async route handlers must either handle all thrown exceptions with try/catch or use a wrapper that converts rejections into next(err) calls. An unhandled rejection in an async handler does not crash the process in recent Express 5 versions, but in Express 4 it causes an unhandled promise rejection that crashes the request mid-flight and leaves the client waiting for a response that never arrives — or in some configurations sends a 500 with no logging. The broken version also does not handle the null case from findUnique(), which returns null when no record is found, leading to a null dereference crash. The fix wraps the handler in try/catch, handles the null case explicitly with a 404, and passes genuine errors to the centralized error middleware using next(err). The four-parameter signature (err, req, res, next) is required for Express to recognize the middleware as an error handler.
A serverless deployment creates new Prisma clients per invocation, exhausting the database connection limit.
Prisma connection pool exhaustion causing 500s under load
❌ Wrong
// Lambda handler — creates new PrismaClient per invocation
const { PrismaClient } = require('@prisma/client');
exports.handler = async (event) => {
// New client per invocation = new connection pool per invocation
const prisma = new PrismaClient();
const users = await prisma.user.findMany({
where: { active: true }
});
// Client never disconnected — connections leak
return { statusCode: 200, body: JSON.stringify(users) };
};✅ Fixed
const { PrismaClient } = require('@prisma/client');
// Singleton — reused across warm Lambda invocations
let prisma;
function getPrismaClient() {
if (!prisma) {
prisma = new PrismaClient({
log: ['error', 'warn'],
datasources: {
db: { url: process.env.DATABASE_URL }
}
});
}
return prisma;
}
exports.handler = async (event) => {
const db = getPrismaClient();
try {
const users = await db.user.findMany({
where: { active: true }
});
return { statusCode: 200, body: JSON.stringify(users) };
} catch (err) {
if (err.code === 'P2024') {
// P2024 = connection pool timeout
console.error('Database connection pool exhausted:', err.message);
return { statusCode: 503, body: JSON.stringify({ error: 'Service temporarily unavailable' }) };
}
console.error('Database error:', err);
return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) };
}
};PrismaClient opens a connection pool to the database when instantiated. Creating a new PrismaClient on every Lambda invocation means every concurrent invocation opens its own pool — 100 simultaneous Lambda invocations can open 500 or more database connections if the pool size is 5 per client. Most managed database services cap connections at 100 or fewer, so connection exhaustion happens quickly under any real load. The fix uses a singleton pattern: the client is initialized once per container and reused across warm invocations. Lambda containers are reused for subsequent invocations (warm starts), so the singleton's pool persists. Cold starts create a new container with a fresh client. Prisma error code P2024 specifically indicates connection pool timeout, so it gets a 503 response (Service Unavailable) rather than 500, which is more accurate and signals to clients that they can retry.
A production deployment with a missing environment variable that causes initialization code to throw.
Missing environment variable causing silent 500
❌ Wrong
# Python FastAPI — missing env var crashes at request time
from fastapi import FastAPI
import psycopg2
import os
app = FastAPI()
@app.get('/api/health')
def health_check():
# DATABASE_URL missing -> KeyError crashes the request
conn = psycopg2.connect(os.environ['DATABASE_URL'])
conn.close()
return {'status': 'ok'}✅ Fixed
from fastapi import FastAPI, HTTPException
import psycopg2
import os
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
# Validate env vars at startup, not at request time
DATABASE_URL = os.environ.get('DATABASE_URL')
if not DATABASE_URL:
raise RuntimeError('DATABASE_URL environment variable is required but not set')
@app.get('/api/health')
def health_check():
try:
conn = psycopg2.connect(DATABASE_URL)
conn.close()
return {'status': 'ok'}
except psycopg2.OperationalError as err:
logger.error('Database connection failed: %s', err)
raise HTTPException(status_code=503, detail='Database unavailable')
except Exception as err:
logger.exception('Unexpected error in health check')
raise HTTPException(status_code=500, detail='Internal server error')Accessing os.environ['DATABASE_URL'] at request time means every request fails with a 500 when the variable is missing, and the error message in the 500 response body might leak the variable name to clients. The fix validates required environment variables at application startup using a guard clause that raises RuntimeError immediately. A startup failure is much more visible than a request-time failure: the deployment fails, health checks fail, and the problem is caught before any user traffic arrives. Database connection errors are mapped to 503 Service Unavailable rather than 500 Internal Server Error, which is semantically correct and signals that the service is temporarily down rather than buggy. The logger.exception() call logs the full stack trace for unexpected errors without exposing it to the HTTP client.
What generates a 500 response on the server
A 500 Internal Server Error is the server's way of admitting that something went wrong on its side — the request was valid, but the server failed to process it. The HTTP specification says 500 should be used when the server encounters an unexpected condition that prevents it from fulfilling the request. In practice, 500 is the default response when application code throws an uncaught exception and the framework does not have more specific handling for that error.
In Node.js with Express, the most common source of 500s is an async route handler that throws an exception without a try/catch. Express 4's default error handling catches synchronous throws inside route handlers but does not catch rejected Promises in async functions. A single null dereference, a failed JSON parse, or a rejected database query causes the entire request to fail. The client receives a 500, and depending on the Express configuration, the response body may be the raw error stack trace (development mode) or a generic message (production mode).
Database failures are another leading cause. When the connection pool is exhausted — every available connection is in use by long-running queries or leaked connections — new queries queue up waiting for a slot. When the queue wait exceeds the pool timeout, the database client throws an error that propagates as a 500 if not explicitly handled. Prisma throws P2024 for pool timeouts; pg throws a pool timeout error. Both appear as 500s to the client unless the route handler catches them and returns 503.
Environment variable misconfigurations produce 500s that are particularly disruptive because they affect every request uniformly. When a secret is rotated in production but the running application still holds the old value, every request that uses that credential fails. When a required environment variable is missing in a new deployment, every cold start crashes. These failures are often caught after deployment by a sudden spike in 500 error rates across all endpoints simultaneously.
Out-of-memory (OOM) crashes in Node.js are silent 500 sources. When the Node.js heap reaches its configured limit (default 1.5GB, adjustable via --max-old-space-size), the V8 garbage collector runs more frequently, then the process crashes. Pending requests at the moment of crash receive connection resets rather than 500 responses. After the process restarts, a brief window of 500s appears while the new process initializes. Memory leaks — objects retained by closures, event listeners not removed, or caches without eviction — cause gradual memory growth that eventually triggers OOM.
Reading logs and traces to find the crash
The response body of a 500 error rarely contains the actual cause — well-configured production servers return generic messages to avoid leaking internal details. The server logs are where the real information lives. Start by searching logs for the timestamp of the failing request and finding the stack trace logged immediately after or before it. The stack trace contains the exact file path, function name, and line number where the exception originated.
In Node.js, two global event handlers capture crashes that bypass route-level try/catch. process.on('uncaughtException', handler) fires when a synchronous exception is thrown outside any try/catch in the Event Loop. process.on('unhandledRejection', handler) fires when a Promise is rejected and no .catch() handler catches it. Both should be configured to log the full error with stack trace and then allow the process to exit cleanly — continuing after an uncaughtException is unsafe because the process may be in a corrupted state.
In the browser, open DevTools Network tab and click on the failing 500 request. Check the Response tab — even though the body is often generic, some APIs include an error code or request ID that can be cross-referenced with server logs. The request headers, specifically X-Request-ID or Trace-ID headers, allow correlating the browser request with the server log entry that contains the stack trace.
For serverless deployments, CloudWatch Logs (AWS) or Function logs (Vercel/Netlify) contain all output from the function invocation. Filter logs by the request timestamp or the function request ID to find the specific invocation that failed. Serverless platforms log the error and stack trace before recording the function's exit status. AWS Lambda also logs the billed duration and memory used, which can reveal OOM issues (memory used equals memory limit) or timeout issues (duration equals the configured timeout).
For distributed systems, use a correlation ID or trace ID that is generated at the API gateway and passed to every downstream service. When a 500 propagates from a microservice back to the client, the trace ID allows following the call chain through multiple service logs to find which service actually threw the exception. Tools like Sentry, Datadog APM, and OpenTelemetry automatically propagate trace context. The /tools/http-request-builder on ToolDock can add a custom X-Trace-ID header to your test requests, making it easy to grep server logs for that specific request.
Eliminating 500s at the source
The most impactful single change for reducing 500 errors in Express applications is adding a centralized error handler middleware at the end of the middleware stack. An Express error handler takes four parameters: err, req, res, next. When any route calls next(err) or throws synchronously, Express skips all remaining regular middleware and invokes the error handler. The error handler logs the full error and stack trace, then sends a structured error response with an appropriate status code — 500 for unhandled bugs, 503 for dependency failures, 422 for validation errors that slipped through to the handler.
For individual route handlers, wrap the entire async function body in try/catch. Catch specific error types first and handle them appropriately. A database not-found error maps to 404. A connection pool timeout maps to 503. An unexpected error of unknown type maps to 500. This specificity prevents the blunt instrument of treating every exception as a 500 and makes the API more useful to clients by communicating the correct status code.
For null reference errors — the most common crash cause — adopt defensive patterns consistently. Use optional chaining for deep property access: user?.profile?.avatarUrl rather than user.profile.avatarUrl. Add explicit null checks after database queries that may return null. For external API responses, validate the shape before accessing nested properties — a response that was expected to have data.users but actually has an error key will crash any code that assumes data.users exists.
For database connection pool exhaustion, configure the pool size based on the database's connection limit and the number of application instances. The formula is: database max connections / number of app instances = max pool size per instance, with a buffer for administrative connections. In Prisma, set connection_limit in the DATABASE_URL query string. Reduce pool size in serverless environments where many containers may run simultaneously. Monitor connection count using the database's built-in monitoring and set alerts before the limit is reached.
For missing environment variables, validate all required variables at startup and fail fast with a clear error message if any are missing. This surfaces configuration errors at deployment time rather than after user traffic starts. Use a validation library like zod or envalid to define the expected environment shape and get descriptive error messages when variables are missing or have wrong types.
500 errors that come from outside your code
Upstream dependency failures produce 500s in your service even when your code is correct. If your route handler calls a third-party API that returns 500, and your code propagates that error without mapping it to an appropriate response, your clients see a 500 from your service. Build a clear boundary between upstream failures and your service's own failures: log the upstream error with its status code and response body, then return a 503 with a message indicating a dependency is unavailable. This distinction helps operations teams quickly determine whether the incident is your service's fault or a dependency's fault.
Middleware that runs before route handlers can generate 500s that bypass the route entirely. If JSON body parsing middleware fails because the request body is malformed JSON, Express's default behavior is to call next(err) with a SyntaxError. If no error handler catches this, it becomes a 500. Add explicit handling for body parsing errors in the centralized error handler and return 400 Bad Request with a message indicating the body could not be parsed.
Cold start issues in serverless functions cause initialization-time 500s that look like request-time 500s in logs. If the function's top-level code — database connection setup, configuration loading, or SDK initialization — throws during cold start, the runtime catches it and records a 500 for the incoming request without even invoking the handler function. The log entry for these failures appears before any handler logs. Check whether the error occurs before or after handler entry to distinguish cold start failures from handler failures.
Vercel and similar platforms bundle the application code before deploying. If a dependency is missing from package.json or has a native module that does not compile for the target platform, the bundle fails silently in some configurations and the function crashes with a MODULE_NOT_FOUND error at runtime. This appears as 500 on every request. Check the deployment build log for any import errors and verify that all production dependencies are listed under dependencies, not devDependencies.
Database schema mismatches after a migration produce 500s that affect only code paths touching new or changed columns. If a migration added a non-nullable column without a default value and the application was deployed before the migration ran, any INSERT that omits the new column fails with a constraint violation. These 500s affect only writes, not reads, and only to the affected table. Check recent migration history when 500s affect only specific endpoints.
500 mistakes developers make repeatedly
Logging error.message without error.stack is the most common logging mistake that makes 500 diagnosis harder. error.message contains the exception type and summary, but error.stack contains the exact file path and line number where the exception was thrown. A log line that says TypeError: Cannot read properties of undefined with no stack trace requires guessing which of thousands of code lines caused it. Always log error.stack or use a logger that automatically includes stack traces for Error objects.
Swallowing errors in catch blocks without re-throwing or logging creates invisible failures. Code like catch (err) { return null; } causes the caller to receive null when it expected an object, and the downstream null dereference produces a 500 with a stack trace that points to the calling code rather than the real error location. Every catch block should either handle the error explicitly (log it, return a fallback, return an error response) or re-throw it to propagate to the centralized handler.
Not configuring process.on('unhandledRejection') in Node.js applications means that rejected Promises without a .catch() handler are silently dropped in some versions or crash the process in others. Node.js 15+ defaults to crashing on unhandledRejection, which is the correct behavior for production, but earlier versions silently swallow the rejection. Always configure: process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled rejection:', reason); }). This ensures no rejection goes unlogged.
Returning 500 for all errors without differentiating by error type creates poor API contracts. A 500 signals a bug or unexpected condition. A 503 signals that a dependency is down and the client should retry later. A 422 signals that the request data failed validation that should have been caught earlier. Returning 500 for connection pool exhaustion makes clients think your code is broken when the real issue is temporary capacity — they should retry, not open a bug report. Map error types to the most semantically accurate status code.
Forgetting to add await before async calls causes non-obvious 500s. Without await, the async function returns a Promise object immediately. Code that then calls a method on the assumed result crashes with TypeError. The error message mentions the method name and undefined, which makes it look like a null reference at that line, but the actual bug is the missing await one line earlier. TypeScript with strict mode catches many of these at compile time.
Building servers that surface errors clearly
Set up structured error logging from day one. Use a logging library that outputs JSON (pino in Node.js, structlog in Python) and includes the stack trace, request ID, user identifier if available, and timestamp in every error log entry. Structured logs are machine-readable — they can be ingested by Datadog, CloudWatch Insights, or Elasticsearch and queried with filters like status:500 in the last 1 hour grouped by route. Unstructured console.error() logs become impossible to query at scale.
Integrate an error monitoring service like Sentry or Honeybadger early. These tools automatically capture unhandled exceptions with full context: stack trace, request headers, request body (sanitized), user identifier, environment, and deployment version. When a 500 occurs in production, Sentry sends an alert within seconds and links to the exact stack trace and a count of affected users. This cuts time-to-diagnosis from hours of log searching to under a minute of reading the Sentry alert.
Implement request-level tracing with a generated trace ID. At the entry point — API gateway, load balancer, or the first middleware in Express — generate a UUID and attach it to the request object. Log the trace ID with every log line for that request. Return it to the client in a X-Trace-ID response header. When clients report a 500, they can provide the trace ID, which allows support engineers to immediately pull all server logs for that specific request without sifting through unrelated log entries.
Write integration tests for error paths, not just success paths. Test that your route returns 404 when a resource is not found rather than crashing. Test that a database error produces a structured 500 response rather than leaking a stack trace. Test that malformed request bodies return 400 rather than 500. These tests are the most valuable regression prevention because they catch error-handling regressions before deployment.
For production deployments, configure health check endpoints that test connectivity to every dependency: database, cache, and any required external APIs. Use the /tools/http-request-builder on ToolDock to hit your health check before and after deployment to confirm dependencies are reachable. A deployment that breaks the database connection string fails the health check immediately, stopping the deployment before user traffic routes to broken instances. This prevents the most common source of post-deployment 500 spikes.
Quick fix checklist
- ✓Check server logs at the exact timestamp of the 500 — find the stack trace with the file and line number
- ✓Add try/catch to every async Express route handler and call next(err) to route to the error handler
- ✓Add a four-parameter Express error handler (err, req, res, next) at the end of the middleware stack
- ✓Configure process.on('unhandledRejection') to log and exit cleanly rather than swallowing silent failures
- ✓Check database connection pool settings — pool size should not exceed the DB max connections divided by app instance count
- ✓Verify all required environment variables are present in the deployment environment, not just locally
- ✓Add null checks after any database query that returns a single record — findUnique returns null, not an exception
- ✓Set up Sentry or equivalent error monitoring to capture future 500s with full context automatically
Related guides
Frequently asked questions
Where do I look to find the cause of a 500 error?
Check server logs first — the response body rarely contains the real cause in production. Search logs for the exact timestamp of the failing request. Look for exception messages and stack traces logged at that time. The stack trace contains the file name and line number where the crash originated. In Node.js, also check for unhandledRejection events. In serverless environments, check CloudWatch Logs or the platform's function log viewer.
Why does my API return 500 only sometimes, not every request?
Intermittent 500s usually point to race conditions, data-dependent code paths, or resource exhaustion. If the 500 correlates with specific request parameters, the problem is in a code path that only executes for certain inputs — a null check missing for an optional field, or an edge case in data parsing. If the 500 correlates with traffic volume, suspect connection pool exhaustion, memory pressure, or rate limiting by an upstream dependency.
What is an unhandled promise rejection and how does it cause 500s?
An unhandled promise rejection occurs when a Promise is rejected and no .catch() handler or try/catch around await catches the rejection. In Express 4, async route handlers that throw without try/catch create unhandled rejections that leave the HTTP response pending. Express does not automatically send a 500 — the client hangs. In Node.js 15+, unhandled rejections crash the process, which causes an abrupt connection reset to the client. Always use try/catch in async Express handlers.
How does database connection pool exhaustion cause 500s?
When every connection in the pool is in use by active queries and a new query needs a connection, it waits in a queue. If the wait exceeds the pool timeout, the database client throws an error that propagates as a 500 if not caught. This commonly happens when slow queries hold connections for a long time, when connection leak occurs because connections are never released, or when too many application instances share a database with a low connection limit.
Should a 500 response body include the error details?
In production, no — exposing stack traces, database error messages, or internal paths in the response body leaks implementation details that attackers can use. Return a generic message and a request ID that support teams can use to look up the real error in logs. In development, include the stack trace in the response body for faster debugging. Use environment-based conditional logic to switch between the two behaviors.
Why is my Vercel function returning 500 after deployment?
Common Vercel-specific 500 causes include: function timeout exceeded (10 seconds on Hobby, 60 on Pro), bundle size limit exceeded causing import failures, environment variables not set in Vercel dashboard for the deployment environment, or a native Node.js module that does not compile for the serverless runtime. Check Vercel's function logs in the dashboard for the specific error. Also verify all environment variables are set under the correct environment (Production, Preview, Development).
What is the difference between a 500 and a 503 response?
A 500 Internal Server Error means the server's own code crashed due to an unexpected condition. A 503 Service Unavailable means the server is temporarily unable to handle the request — typically because a dependency like a database or external API is down. Use 503 when the problem is transient and the client should retry later. Use 500 for genuine bugs. Correct differentiation helps clients and monitoring systems respond appropriately: 503s trigger retry logic, while 500s trigger bug reports.
How does Sentry help with 500 errors?
Sentry automatically captures every unhandled exception with the full stack trace, request context (headers, body, URL), user identifier, environment, and software version. It groups repeated errors together, shows a count of affected users, and notifies your team via Slack or email within seconds of the first occurrence. This cuts the typical 500 diagnosis time from hours of log searching to under a minute of reading the Sentry issue. Set up Sentry's Node.js or Python SDK and the Express error handler integration for automatic capture.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.