HTTP 503 Service Unavailable: Meaning, Causes, and Fixes

HTTP 503 Service Unavailable means the server cannot handle the request right now because it is overloaded or in scheduled maintenance, and it expects to recover. Read Retry-After, wait that long, and retry safe requests. If you run the server, shed load or finish the maintenance, then reopen traffic.
503 sits in the 5xx group of HTTP status codes, but it is the one that carries the most information. It says the failure is a capacity or availability problem rather than a surprise, and it can tell the client roughly how long to wait. This page covers the definition, the response shape, the codes next to it, the causes, and how to test it without a flaky suite.
What the RFC says about 503
RFC 9110 section 15.6.4 opens with: "The 503 (Service Unavailable) status code indicates that the server is currently unable to handle the request due to a temporary overload or scheduled maintenance..." The sentence goes on to say the condition will probably be alleviated after some delay, and that the server may send Retry-After.
The same section adds something a test author needs to know: an overloaded server does not have to answer with 503 at all. It may simply refuse the connection. So a test that generates load and waits for a 503 is testing chance.
Maintenance and overload share one code, which means the code alone does not say which one you are in. Put that in the body, and give planned maintenance a Retry-After value that is worth reading.
What a 503 response must carry
Retry-After is optional, and on a 503 it means how long the service expects to stay unavailable. It takes an HTTP date or a non-negative number of whole seconds (RFC 9110 section 10.2.3).
A 503 is not heuristically cacheable, and is storable only under the general rules plus explicit controls (RFC 9111 sections 3 and 4.2.2). Send Cache-Control: no-store so an intermediary does not keep serving the outage after the service has recovered. A deliberate short edge cache to protect a struggling origin is a separate decision and needs its own explicit freshness.
The header carries the retry instruction, not a JSON field. The body below is problem details, the JSON error format from RFC 9457, with requestId as an extension member.
GET /api/orders/ord_123 HTTP/1.1
Host: api.example.com
Accept: application/json
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 120
Cache-Control: no-store
{
"type": "https://api.example.com/problems/maintenance",
"title": "Service Unavailable",
"status": 503,
"detail": "Order service is in scheduled maintenance. Retry after 120 seconds.",
"requestId": "req_7f3a"
}
The 120 means wait 120 seconds from the moment the response arrived, not from any fixed clock time.
HTTP 503 vs 502, 504, and 429
| Code | Use it when | Distinction | Source |
|---|---|---|---|
| 502 Bad Gateway | A gateway or proxy received an invalid response from upstream | Upstream replied, invalidly | RFC 9110 section 15.6.3 |
| 503 Service Unavailable | Temporary overload or scheduled maintenance | Capacity or availability. Can come from the origin, a proxy, or a gateway | RFC 9110 section 15.6.4 |
| 504 Gateway Timeout | A gateway or proxy received no timely response from upstream | Upstream was too slow | RFC 9110 section 15.6.5 |
| 429 Too Many Requests | This client or identity sent too many requests in a window | A limit on one caller, not on the service. May also carry Retry-After | RFC 6585 section 4 |
The remaining neighbour is 500, which is unexpected and says nothing about duration. If you know the condition is temporary, 503 tells the caller far more.
What causes an HTTP 503 and how to fix it
One thing first: returning a 503 communicates the condition. It does not fix the resource that ran out.
| Cause | Check | Fix |
|---|---|---|
| Scheduled maintenance | The deploy window, the maintenance switch, a running migration, drained instances | Stop new work cleanly, keep a light 503 responder up, send Retry-After, and restore traffic only after readiness checks pass. |
| CPU, memory, worker, pool, or queue exhaustion | Resource graphs, queue depth, pool limits, and concurrency at the moment of failure | Bound the queue, shed the excess with 503, then remove the bottleneck or add capacity. |
| The origin cannot keep up with the request rate, behind CloudFront | Origin metrics and logs | Reduce the rate or add origin capacity. CloudFront says a 503 usually points at origin performance, though edge limits can cause one too. |
| A backend overloaded or in maintenance behind an API gateway, in Apigee | The gateway trace, the NGINX access log, and a direct call to the backend | Fix or scale the backend. If the direct call works and the proxied one fails, the problem is in the gateway path. |
| An IIS worker crash or a missing module | A stopped application pool, Windows event logs, Rapid Fail Protection events | Fix the crashing code or module, then restart the pool. Raising the crash protection threshold only hides it. |
| A CDN or edge failure, on Cloudflare | Whether the body contains cloudflare or cloudflare-nginx, plus Workers logs and the cdn-cgi trace | No branding means investigate origin limits. Branding present means capture the domain, the exact time with timezone, and the trace output for Support. For Workers, fix the CPU or memory limit failures in the logs. |
Sources for those rows, in order: RFC 9110 section 15.6.4, MDN, CloudFront, Apigee, the IIS support blog, and Cloudflare.
The order of operations that works, whichever row you are in: find who issued the 503 (origin, proxy, or CDN), inspect capacity and logs at that layer, call the origin directly to see whether it answers, fix the cause, and verify recovery before reopening traffic. Continuous uptime monitoring is what tells you which layer went first.
Express
Reserve this for a known maintenance or overload gate. It should not be where every unhandled exception ends up, because that is a 500 and a 503 makes a different promise.
app.use((err, req, res, next) => {
if (err.code !== 'SERVICE_UNAVAILABLE') return next(err);
return res
.set({ 'Retry-After': '120', 'Cache-Control': 'no-store' })
.status(503)
.type('application/problem+json')
.json({
type: 'https://api.example.com/problems/maintenance',
title: 'Service Unavailable',
status: 503,
detail: 'Order service is temporarily unavailable.',
requestId: req.id,
});
});
FastAPI
Return a JSONResponse directly when a dependency check or a maintenance gate says the service is not ready. That gives you the status, the media type, and the headers in one place.
from fastapi.responses import JSONResponse
def service_unavailable():
return JSONResponse(
status_code=503,
media_type="application/problem+json",
headers={"Retry-After": "120", "Cache-Control": "no-store"},
content={
"type": "https://api.example.com/problems/maintenance",
"title": "Service Unavailable",
"status": 503,
"detail": "Order service is temporarily unavailable.",
},
)
Spring
Build the response with ResponseEntity when a readiness or capacity gate is closed, setting the status, both headers, and a ProblemDetail body together.
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.SERVICE_UNAVAILABLE,
"Order service is temporarily unavailable."
);
return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, "120")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(problem);
Cloudflare and CloudFront
On Cloudflare, check whether the response body carries Cloudflare branding. Without it, the origin is the place to look. With it, collect the domain, the exact time and timezone, and the trace output, and take it to Support. On CloudFront, a 503 usually means the origin cannot keep up with the request rate, so the fix is origin capacity or a lower rate, though edge limits can also produce one.
How a client should handle a 503
Read
Retry-After. Support both forms, whole seconds and an HTTP date.Wait at least that long. When the header is missing or unparseable, fall back to capped exponential backoff with random jitter, which spreads returning clients out instead of bunching them.
Limit attempts and surface the final failure. An infinite retry loop turns an outage into client-side congestion on top of it.
Retry GET and HEAD freely. Retry PUT or DELETE only when the semantics really are idempotent, meaning a repeat leaves the same result as one call. Never blindly retry a POST. Google Cloud lists 503 as retryable and warns that retrying non-idempotent work creates duplicates. HTTP methods covers which verbs qualify.
Treat
Retry-Afteras advice. It is the server's estimate, not a guarantee that everything is healthy at that instant.
How to test a 503 response
Create the 503 on purpose, through a test-only maintenance flag, a stubbed dependency, or a mock server. Never overload a live service and hope, because the RFC lets an overloaded server refuse the connection instead of answering. The rest of API testing applies as usual.
import test from 'node:test';
import assert from 'node:assert/strict';
test('maintenance response is a retryable, uncached 503', async () => {
const response = await fetch('http://localhost:3000/__test__/maintenance');
assert.equal(response.status, 503);
assert.equal(response.headers.get('retry-after'), '120');
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.match(response.headers.get('content-type') ?? '', /application\/problem\+json/);
const problem = await response.json();
assert.equal(problem.status, 503);
assert.match(problem.type, /\/maintenance$/);
assert.ok(problem.requestId);
});
The flaky version fires an arbitrary number of concurrent requests at shared staging and passes if any of them returns 503. It can just as easily get 200, 429, 502, 504, or a refused connection, all of which are legitimate answers. That is not a test of the 503 contract, it is a small load test with an assertion bolted on. Force the state, then assert one contract, and you have removed the main source of flaky tests in this area.
How Qodex flags a 503
This is the status code where classification matters most. Qodex runs its tests against the preview on every pull request and sorts each failure into a real bug, a stale test with a proposed repair diff, or an environment issue. A preview that is still deploying answers 503, and that lands as an environment issue rather than a red test. A 503 that breaks the contract, with no Retry-After or the wrong body, lands as a real bug with the response attached. See how Qodex API testing works.
Related status codes
HTTP 500 Internal Server Error. The unexpected failure, with no promised recovery.
HTTP 429 Too Many Requests. The per-client limit that also uses Retry-After.
HTTP 401 vs 403. Identity versus permission.
HTTP 422 Unprocessable Content. Valid syntax that still fails a rule.
HTTP status codes. The full list, 1xx to 5xx.
API testing. What to assert and how to run it in CI.
Frequently Asked Questions
What does HTTP 503 Service Unavailable mean?
The server cannot handle the request right now because of temporary overload or scheduled maintenance, and it expects to recover. RFC 9110 section 15.6.4 defines it, and it says nothing about duration.
Is a 503 error always temporary?
By definition it describes a temporary condition, but the RFC attaches no duration and no guarantee. Treat Retry-After as an estimate from the server, not as a promise that the service is back by then.
What is the difference between 500, 502, 503, and 504?
500 is an unexpected failure in the server itself. 502 means a proxy got an invalid reply from upstream. 503 is temporary overload or maintenance. 504 means a proxy got no reply in time.
What is the difference between HTTP 429 and HTTP 503?
429 means one client went over its own rate limit. 503 means the whole service is unavailable to everyone. Both may send Retry-After, so the status code is what tells the two apart.
Should a client automatically retry a 503 response?
Yes for GET and HEAD, after the Retry-After delay or a jittered backoff, with a cap on attempts. Not for POST, unless an idempotency key makes replaying the request safe.
What does the Retry-After header mean on a 503?
How long the service expects to stay unavailable, given as whole seconds or an HTTP date. Retry-After 120 means wait two minutes from the moment the response arrived, then try again.
Is an HTTP 503 response cacheable?
Not by default. Send Cache-Control no-store so an intermediary does not keep serving the outage after the service has recovered. A deliberate short edge cache is a separate decision and needs explicit freshness.
How do you test a 503 response without creating a flaky test?
Trigger it deliberately with a test-only maintenance flag or a stubbed dependency, then assert the status, Retry-After, Cache-Control, and the body. Overloading a real server produces a different answer every run.




