Skip to content
Control Plane Labs

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