503 Service Unavailable
Server error response defined by RFC 9110 §15.6.4.
Last updated July 27, 2026
Common causes at a glance
- Maintenance mode deliberately enabled at the edge during a deploy or migration
- Load balancer with no healthy targets because readiness probes are failing
- Application shedding load when a queue or worker pool is saturated
- Rate limiting or a WAF returning 503 instead of 429 for throttled clients
Reproduce it
curl -sSI https://www.example.com/ | grep -Ei '^(HTTP|retry-after)'What 503 Service Unavailable means
503 is the honest code for “not now, try again”. Three deployments dominate. Maintenance mode, where an operator flips a flag and the edge returns 503 for everything while a migration runs. Load shedding, where the application is up but its queue is past a threshold and it rejects work rather than accepting requests it cannot finish. And the one people find most confusing, a load balancer with zero healthy targets.
That last case deserves care. An ALB, an nginx upstream pool, or a Kubernetes Service with no ready endpoints has nothing to forward to, so it answers 503 itself. The backends may be healthy processes merely failing a health check — a readiness probe pointed at a path requiring authentication, or a check with a one-second timeout against a route that takes two. The 503 is then a monitoring bug wearing the costume of an outage.
What the spec says
RFC 9110 §15.6.4 frames 503 as temporary overload or
scheduled maintenance that will likely be alleviated after some delay, and says the
server MAY send Retry-After, defined in
§10.2.3, as a delay in seconds or an HTTP-date. A note in the
same section is worth quoting to anyone designing an overload policy: the existence of
503 does not imply a server has to use it when becoming overloaded, since refusing the
connection is also legitimate. 503 is not cacheable by default, which matters when a CDN
sits in front.
What actually causes it
Rolling deploys produce most transient 503s: old pods terminate before
new ones pass readiness, and for a few seconds the pool is empty. Persistent 503s after
a deploy usually mean the health check broke — the probe path moved, or it now requires
a header the checker does not send. Then the miscoded case, a service returning 503 for
per-client throttling where 429 is correct and carries the same
Retry-After semantics without telling monitoring the service is down. If
your 503 rate is high but latency and error logs are clean, look for a rate limiter
before a fault.
How to debug it
Start with headers, because Retry-After and Server
together identify the source:
curl -sSI https://www.example.com/ | grep -Ei '^(HTTP|server|retry-after|via)'
A 503 from your application will look like your application. A 503 from an intermediary
will not, and generally means the request never reached a backend. Next, reproduce the
health check exactly, using the same path, method, and timeout the checker uses. Watch
for the mismatch where the probe requests /healthz but the route answers
only /health. If the pool is healthy and 503s persist, look at concurrency
limits and queue depth in the application, not the balancer.
Working examples
curl
# Retry-After and Server together tell you who is refusing, and for how long.
curl -sSI https://www.example.com/ | grep -Ei '^(HTTP|server|retry-after|via)'
# Reproduce the load balancer's health check exactly, from inside the network.
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' \
--max-time 2 http://10.0.1.24:8080/healthz
# Sample over a minute to see whether it is constant or a deploy blip.
for i in $(seq 1 30); do
curl -sS -o /dev/null -w '%{http_code} ' https://www.example.com/; sleep 2
done; echo
Python (requests)
import requests
resp = requests.get("https://www.example.com/", timeout=10)
print(resp.status_code, resp.headers.get("Server"))
if resp.status_code == 503:
# Retry-After is seconds or an HTTP-date; honour it instead of hammering.
print("retry after:", resp.headers.get("Retry-After") or "not specified")
# Exercise the readiness probe the way the balancer does.
probe = requests.get("http://10.0.1.24:8080/healthz", timeout=2)
print("probe:", probe.status_code, probe.elapsed.total_seconds())
Node (fetch)
const res = await fetch("https://www.example.com/");
console.log(res.status, res.headers.get("server"));
if (res.status === 503) {
const retryAfter = res.headers.get("retry-after");
// Seconds, or an HTTP-date. Fall back to your own backoff if absent.
const waitMs = /^\d+$/.test(retryAfter ?? "") ? Number(retryAfter) * 1000 : 5000;
console.log("waiting", waitMs, "ms before retry");
await new Promise((r) => setTimeout(r, waitMs));
console.log("retry:", (await fetch("https://www.example.com/")).status);
}Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.
Other 5xx codes
- 500 Internal Server Error
- 501 Not Implemented
- 502 Bad Gateway
- 504 Gateway Timeout
- 505 HTTP Version Not Supported
- 506 Variant Also Negotiates
- 507 Insufficient Storage
- 508 Loop Detected
- 510 Not Extended
- 511 Network Authentication Required
- 520 Web Server Returns an Unknown Error
- 521 Web Server Is Down
- 522 Connection Timed Out
- 523 Origin Is Unreachable
- 524 A Timeout Occurred
- 525 SSL Handshake Failed
- 526 Invalid SSL Certificate
- 530 Unable to Resolve Origin Hostname