HTTP 200 is OK: the request succeeded, and the body carries the result. RFC 9110 section 15.3.1 ties that body to the method: the representation for GET, the same headers with no body for HEAD, the outcome of the action for POST, PUT and DELETE. A 200 is expected to have content; when there is none to send, 204 says so.
Picking the right 2xx
GET /orders/42 200 the order
POST /orders 201 Created, with Location: /orders/42
PUT /orders/42 (exists) 200 with the new state, or 204 with none
PUT /orders/43 (new) 201 Created
POST /exports 202 Accepted: queued, not done yet
DELETE /orders/42 204 No Content
GET with a Range header 206 Partial Content
GET /orders?status=lost 200 with an empty list, not 404 or 204
The PUT rows are a rule, not a style: RFC 9110 section 9.3.4 says a PUT that creates must answer 201, and one that replaces must answer 200 or 204.
The 200 with an error inside
200 {"error": "not found"} tells every cache, proxy, monitor and retry library that the request worked. A CDN may store it, an uptime check stays green, and a client that branches on response.ok takes the success path. On web pages the same mistake is what Google calls a soft 404: a “not found” template served with 200, which Search Console reports. Send the real 4xx or 5xx and put the detail in the body. GraphQL is the well-known exception: servers commonly answer 200 with an errors array, because one response can carry partial data and errors together.
Caching
200 is heuristically cacheable (RFC 9110 section 15.1): with no Cache-Control or Expires, a cache may choose its own freshness lifetime. RFC 9111 section 4.2.2 suggests no more than a fraction, typically 10%, of the time since Last-Modified, so a file last changed 30 days ago can be reused for up to about three days without asking. That’s the usual cause of “users still see the old version”. Set Cache-Control on every 200 that matters: no-cache to revalidate each time, max-age=31536000, immutable for fingerprinted assets.
Health checks
An AWS ALB health check counts only 200 as healthy unless you widen its success codes, for example to 200-299. Kubernetes HTTP probes accept any code from 200 to 399.
In your stack
Every framework sends 200 when you don’t choose:
res.json(order); // Express
return order # Flask and FastAPI; Django: return JsonResponse(order)
json.NewEncoder(w).Encode(order) // the first Write sends 200 if WriteHeader wasn't called
location = /healthz {
default_type text/plain;
return 200 "ok\n";
}