503 Service Unavailable
Server error response defined by RFC 9110 §15.6.4.
Written and maintained by Ben Ennis
Last reviewed July 27, 2026 · How we verify this
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.
The preStop race: why a 503 blip survives a “healthy” rolling deploy
The zero-healthy-targets case above understates a subtler timing problem. When Kubernetes
terminates a pod, two systems act in parallel rather than in sequence: the kubelet runs
the container’s preStop hook and then sends SIGTERM, while
separately the endpoint controller removes the pod from EndpointSlices and every
kube-proxy updates its routing rules. The
Kubernetes Pod Lifecycle documentation
confirms terminating endpoints are marked not-ready immediately, but propagating that
removal to every proxy and ingress controller in the cluster is not instantaneous. If
your application finishes shutting down before that propagation completes, requests keep
arriving at a pod that has already stopped listening, and the caller sees a 503 (or a
reset connection) even though every readiness probe in the cluster’s history shows green.
This is why a preStop hook that simply sleeps for a few seconds before the
main process exits is standard practice, not superstition: it buys time for the endpoint
removal to reach every proxy before the pod actually stops accepting connections. A
typical pattern is a readiness endpoint that starts failing the instant
SIGTERM is received, combined with a preStop delay of 5 to 15
seconds depending on cluster size, since larger clusters and polling-based ingress
controllers propagate endpoint changes more slowly than a small cluster using watches.
If your 503 spike during deploys is brief, uncorrelated with actual pod crash-loops, and
disappears when you scale the cluster down, suspect this propagation window rather than
application startup time — the fix is a longer preStop delay, not a faster
readiness probe.
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.
Related reading
- SLOs, SLIs, and error budgets: a primer
This is the kind of error you should be measuring against an error budget rather than paging on individually. How to pick an indicator and alert on burn rate.
- Karpenter vs cluster-autoscaler in 2026
On Kubernetes this status often tracks node churn rather than application failure. How the two autoscalers differ now that they share an API.
- Every HTTP status code, and when it shows up
The wider map: which codes you actually meet in production, and the pairs everyone confuses.
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