Skip to content

200 OK, Zero Bytes: How Platforms Say No Without Telling You

The most effective bot detection does not return 403. It returns a successful response with nothing in it, and lets you conclude the data was never there.

10 min read · 20 Aug 2026

Your request succeeded. Status 200. No error in the log, no exception, nothing for your retry logic to catch.

The body is empty.

You will spend the next hour looking in the wrong place, because every instrument you own is telling you the request worked. Your HTTP client's res.ok is true. Your error rate is flat. Your monitoring is green. The only thing wrong is that the data is not there, and the most natural conclusion — the one the response was designed to lead you to — is that the data never existed in the first place.

It usually did. You were blocked. The block just did not introduce itself.

This post is about that failure mode: why sophisticated platforms prefer it to a 403, the six shapes it comes in, why your HTTP client makes it worse, and how to detect it without waiting for a customer to report missing data. Which platforms and endpoints actually behave this way, with dates, is tracked in what you can get from each platform without an API key.


Why a platform would rather lie quietly

Start with the platform's problem. They want to serve real browsers and not serve automated clients at scale. They have a detector, the detector is imperfect, and they have to decide what to do when it fires.

The obvious answer is 403 Forbidden. It is honest, it is correct HTTP, and it is the worst possible choice from their perspective, for one reason: a 403 is a signal, and a signal can be automated against.

Give a competent engineer a 403 and within a day they have a feedback loop. Rotate the IP, retry, log the result. Change the user agent, retry, log. Adjust the TLS fingerprint, retry, log. Each experiment gets a clean binary answer in a couple of hundred milliseconds, and a clean binary answer is all you need to hill-climb your way past a detector. The block has handed the attacker a free oracle.

Now consider the alternative. Return 200, with an empty body, or with a valid-looking response that quietly omits the interesting part. What has the engineer learned?

Nothing. They cannot tell whether they were detected or whether this particular video has no captions, this particular profile has no posts, this particular query has no matches. Every experiment is confounded. The feedback loop is broken, not because the signal is encrypted, but because it is ambiguous — and ambiguity is much harder to defeat than encryption.

There is a second reason, more cynical and probably more decisive. A quiet failure moves the support burden onto you. A 403 generates a bug report against the platform. A 200 with no captions generates a bug report against whatever library the user was using. The library maintainer gets the issue, spends a weekend on it, and eventually writes something like "this appears to require a token we cannot generate." The platform's costs are zero throughout.

If you want to see what that looks like from the inside, the YouTube caption gate is the cleanest example we have hit: every caption URL carrying exp=xpe returns 200 with a content-length of zero, in every format, and the leading Python library for the job carries an open issue saying there is no workaround. Nobody at YouTube had to write an error message.


The six shapes

"200 with an empty body" is the most obvious version and not the most common. In practice a quiet failure takes one of six shapes, in rough order of how hard they are to notice.

1. Zero-byte body. Status 200, content-length: 0. Blunt, and at least easy to detect once you know to look — the byte count is right there.

2. Valid response, key omitted. The response parses. It is well-formed. It has fourteen of the fifteen fields you expected, and the one that is gone is the one you wanted. YouTube's InnerTube player endpoint does this: query it for a video whose captions are gated and it returns a complete, entirely valid player response with no captions object at all. Not an empty captions object — no key. Defensive code reads that as "this video has no captions" and moves on, which is exactly the intended reading and exactly wrong.

3. Empty collection where data exists. {"items": []}. Indistinguishable from a genuine zero-result query, which is the point. This is the one that most often survives into production, because "no results" is a legitimate state you already handle.

4. Wrong content type entirely. You asked for JSON and got HTML — a consent interstitial, a login wall, a captcha page, an "unusual traffic" notice. Status 200, because from the server's perspective it successfully served you a page. Your JSON.parse throws a syntax error pointing at character 0, which reads like a bug in your parser rather than a wall in front of your request. YouTube's EU consent interstitial does this, and it is why our watch-page fetches send CONSENT=YES+cb; SOCS=CAI before anything else.

5. Silent truncation. You asked for 200 items and got 30, with no pagination cursor and no indication that more exist. Your data is not missing, it is incomplete, which is worse — incomplete data flows into analysis and produces confident wrong answers rather than obvious failures.

6. Stale or degraded data. The response is complete, well-formed, and from a cache tier that has not been updated in a week. Nothing in the payload says so. This one is nearly undetectable from a single response and only shows up when you compare across time.

Shapes 2, 5, and 6 are the ones that will hurt you, because they pass every check that asks "did I get a response?" and only fail checks that ask "did I get the right response?"


The status code that should have been used

There is a correct status code for "your request succeeded and there is no body": 204 No Content.

It exists, it is unambiguous, it has been in the specification since forever, and it is exactly the semantics a platform would use if it wanted to tell you the truth in the smallest number of bytes. 204 means the operation worked and there is deliberately nothing to return.

Nobody blocking you will send it. Not because of a technical constraint — because 204 is specific, and specificity is the thing they are avoiding. A 204 tells you unambiguously that the emptiness is intentional and server-authored. A 200 with a zero-length body tells you the same thing while leaving room for you to blame yourself.

The practical consequence for your own APIs is the mirror image, and it is worth stating plainly: if your API returns an empty result, say so unambiguously. Use 204, or return 200 with an explicit envelope that distinguishes "we looked and found nothing" from "we could not look." Your callers will build the same broken instincts against your API that you built against everyone else's, and they will blame you for it, and they will be right.


Why your HTTP client makes this worse

Standard tooling is actively unhelpful here, in four specific ways.

response.ok is true. fetch, axios, requests — all of them define success as a 2xx status. A blocked request that returns 200 is a successful request by every definition your library holds. Nothing throws.

Retry logic does not fire. Every retry wrapper you have used keys on status codes and network errors. A 200 is not retried, which is normally correct and is here precisely backwards: this is the case where a single forced retry would tell you the most.

JSON.parse('') throws the wrong error. An empty body produces SyntaxError: Unexpected end of JSON input. That error names your parser, not the network, and it sends you looking at your deserialisation code. Hours disappear here.

Your metrics are green. Success rate, error rate, p99 latency — all healthy. Empty responses are fast, so a wave of them will actually improve your latency numbers while your data pipeline starves. If the only thing you alert on is error rate, this failure mode is invisible by construction.

The common thread: every one of these tools reasons about the transport, and the failure is in the payload. You have to instrument the layer where the failure actually lives.


Detecting it

Four things, roughly in order of value per unit of effort.

Count bytes, not statuses

The cheapest and highest-value change. Have your HTTP layer return the response size alongside the status, and treat a zero-byte 200 as its own outcome — neither success nor a transport error.

const res = await fetch(url);
const body = await res.text();
const bytes = Buffer.byteLength(body, 'utf8');

if (res.ok && bytes === 0) {
  // NOT a success. Not a network error either. Its own thing.
  throw new UpstreamBlocked('Empty body on a 200 response', { url, status: res.status });
}

Log the byte count on every request. A histogram of response sizes per endpoint is one of the most useful instruments you can have against an undocumented API, and it costs nothing: a bimodal distribution with a spike at zero is a gate opening, and you will see it days before anyone reports missing data.

Assert on canary fields

Byte counts catch shape 1. They do nothing for shape 2 — the well-formed response with the key removed — and that requires knowing what a real response looks like.

Declare, per endpoint, a small set of fields that must be present in any genuine response, and check them after parsing:

function detectDrift(def, data) {
  if (!def.driftCanaries?.length || data === null || typeof data !== 'object') return [];
  const target = (Array.isArray(data) ? data[0] : data);
  if (!target) return [];
  return def.driftCanaries.filter((field) => !(field in target));
}

Keep the canary list small — two or three fields that would only be missing if something fundamental changed. A canary list that mirrors your whole schema will fire on every harmless optional field a platform adds, you will start ignoring it, and then it is worse than nothing.

One design decision worth stealing: when canaries are missing, return the data anyway and flag it. Partial data beats no data, and a hard failure on a missing optional field turns a cosmetic upstream change into an outage. Ours marks the endpoint as drifted, logs it, and does not bill for the request.

Distinguish "empty" from "failed", explicitly, in one place

Write the predicate down once, as code, rather than letting each caller improvise:

function isEmpty(data) {
  if (data === null || data === undefined) return true;
  if (Array.isArray(data)) return data.length === 0;
  if (typeof data === 'object') {
    // A wrapper like { items: [] } counts as empty.
    for (const key of ['items', 'results', 'posts', 'comments', 'videos']) {
      if (Array.isArray(data[key])) return data[key].length === 0;
    }
    return Object.keys(data).length === 0;
  }
  return false;
}

The value is not the function. It is that "empty" now has exactly one definition in your codebase, so the answer to "does an empty result count as a success?" is a decision you made deliberately in one place rather than seventeen inconsistent decisions made under deadline.

Retry exactly once, on the empty body

This is the one that surprises people. If a 200 comes back empty, retry it — once, immediately, with whatever state you can plausibly refresh (re-mint the token, rotate the IP, re-fetch the session).

If the retry succeeds, the first response was a transient gate and you have just recovered a request that every conventional retry policy would have discarded as a success.

If the retry also comes back empty, stop. Not three times, not with exponential backoff. A second empty body means something structural changed, and hammering it converts a detection event into a rate-limit event.


The part that costs money

There is a billing dimension to this that is easy to miss until it appears on an invoice.

If you buy from a scraping vendor, ask them directly whether a 200 with an empty body counts as a successful request on your bill. Most vendors will tell you failed requests are not charged, and most vendors' success detectors key on the status code — which means the single most common way to be blocked is, by their definition, a success. That is not necessarily bad faith; it is a detector that reasons about transport, same as your HTTP client does. But it is your money, and it is worth asking the question before you sign up rather than after.

If you are the vendor, the same question points at you. We answered it by refunding on both branches: a request that throws is never charged, and a request that returns an empty result is never charged either, which means a quiet block costs the caller nothing whether or not our detector correctly classified it. That rule exists because the alternative is charging people for zero bytes and hoping the classifier is right, and the classifier is exactly the thing this whole post is about being unreliable.


The short version

  • A 200 with no body is not a success. It is a block that declined to identify itself, and it is a deliberate design choice — an error code is an oracle, and ambiguity is not.
  • It comes in six shapes. The dangerous ones are the well-formed response with a key removed, and the silently truncated list.
  • Your HTTP client, your retry wrapper, and your error-rate dashboard will all report this as healthy. Instrument response size and payload shape, not just status.
  • Retry an empty body exactly once, then stop.
  • 204 No Content is the honest version of this response. Use it in your own API, because nobody blocking you ever will.

Everything described here runs on the same API. You are never charged for a failed request, an empty result, or a cache hit.