Skip to content
Control Plane Labs

204 No Content

Success response defined by RFC 9110 §15.3.5.

Last updated July 27, 2026

Common causes at a glance

  • A successful DELETE with nothing meaningful to return
  • A PUT or PATCH that updated an existing resource in place
  • A CORS preflight OPTIONS response
  • A telemetry or beacon endpoint acknowledging receipt

Reproduce it

curl -i -X DELETE https://api.example.com/v1/widgets/8412

What 204 No Content means

204 is the right answer when the caller already knows everything it needs to know. A DELETE that worked, a PUT that replaced an existing representation, a preference toggle, a beacon: none have anything useful to say, and returning an empty JSON object instead costs bytes and forces the client to parse it.

The header block is still meaningful. An ETag on a 204 response to PUT gives you the validator for the representation you just wrote, usable on the next conditional update without a round trip.

What the spec says

RFC 9110 §15.3.5 states that a 204 response is terminated by the end of the header section and cannot contain content or trailers, and that response header fields refer to the target resource after the action was applied. It is also heuristically cacheable (RFC 9111 §4.2.2), which surprises people who use 204 for state-changing endpoints. The framing rule matters at the transport layer: with no body, a Content-Length other than zero is a framing error, not a hint. Compare 205, which also forbids content but additionally asks the client to reset its form.

What actually causes it

Trouble with 204 is nearly always a client or middlebox that cannot cope with a body that is not there. Older HTTP client wrappers call response.json() unconditionally and blow up on an empty string; the fix is in the caller. Proxies that inject a Content-Length or, worse, an error page body onto a 204 create a framing violation that shows up as a stalled connection or protocol error rather than a clean failure. CORS is the other big source: a preflight OPTIONS answered with 204 is fine, but only if it carries the Access-Control-Allow-* headers, and plenty of frameworks generate a bare 204 for OPTIONS with no CORS headers at all. The browser then reports an opaque CORS failure while curl shows a happy 204, which is why the two disagree.

How to debug it

Confirm the response really is empty and framed correctly:

curl -s -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' -X DELETE https://api.example.com/v1/widgets/8412

Anything other than zero bytes on a 204 means something in the path is appending a body. For a CORS problem, replay the preflight as the browser would, with both Origin and Access-Control-Request-Method, and read the response headers rather than the status. A 204 with no access-control-allow-origin is the failure, even though the status looks successful.

curl -i -X OPTIONS -H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: DELETE' https://api.example.com/v1/widgets/8412

The curl command builder can assemble that preflight for you.

Working examples

curl

# A 204 must download zero bytes. Anything else is a broken intermediary.
curl -s -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' \
  -X DELETE https://api.example.com/v1/widgets/8412

# Replay a CORS preflight and check the headers, not the status:
curl -i -X OPTIONS \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: DELETE' \
  -H 'Access-Control-Request-Headers: authorization' \
  https://api.example.com/v1/widgets/8412

Python (requests)

import requests

resp = requests.delete("https://api.example.com/v1/widgets/8412", timeout=10)
print(resp.status_code, len(resp.content))  # 204 0

# resp.json() raises on an empty body. Guard on the status code first.
data = resp.json() if resp.status_code != 204 and resp.content else None
print(data)

# On a PUT, the ETag from a 204 is the validator for your next conditional write.
put = requests.put(
    "https://api.example.com/v1/widgets/8412",
    json={"size": 4},
    headers={"If-Match": 'W/"a1b2c3"'},
    timeout=10,
)
print(put.status_code, put.headers.get("ETag"))

Node (fetch)

const res = await fetch("https://api.example.com/v1/widgets/8412", {
  method: "DELETE",
});

console.log(res.status, res.ok); // 204 true

// res.json() rejects on an empty body; branch on the status before parsing.
const payload = res.status === 204 ? null : await res.json();
console.log(payload);

// Preflights are issued by the browser, but you can reproduce one directly.
const pre = await fetch("https://api.example.com/v1/widgets/8412", {
  method: "OPTIONS",
  headers: {
    Origin: "https://app.example.com",
    "Access-Control-Request-Method": "DELETE",
  },
});
console.log(pre.status, pre.headers.get("access-control-allow-origin"));

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 2xx codes