One Bot Request Poisoned Our Cache — And Every Real Visitor Got the Broken Page

The report was the worst kind: "ads are sometimes missing, we don’t know when, we can’t make it happen on demand." No steps to reproduce. No obvious pattern by time of day, by page, or by browser. Just: sometimes, on some pages, the ads simply weren’t there — not slow to load, not broken visually, just absent, as if the ad-serving code had never run at all.

That framing turned out to be the biggest clue in the whole investigation, and it took a while to notice it as one.

TL;DR: A single bot request carrying Accept: application/json tripped a content-negotiation guard meant for REST/AJAX calls, silently stripping every ad slot from an otherwise-normal HTML page — then the full-page cache wrote that broken response into the URL’s normal cache key and served it to every real visitor after it. Fix: check the request path/route instead of a spoofable header, and explicitly purge the poisoned cache entries instead of waiting for TTL expiry.

Why Isn’t a "Random" Bug Actually Random?

A pure code bug is either always broken, or broken under a specific, findable condition — a particular post type, a particular user role, a particular plugin combination. What it almost never is, is randomly broken for different visitors hitting the same URL at different times with no discernible pattern. That symptom shape — same URL, same content, inconsistent behavior across requests — is a strong tell for a caching problem, not a logic problem. It took long enough to actually try that framing that it’s worth calling out explicitly here: if "it’s random" is the whole bug report, seriously entertain "cache," early, before spending days trying to reproduce something in a browser.

How Do You Find the Actual Trigger?

The reproduction approach that cracked it wasn’t "try harder in a normal browser" — a normal browser always sends a browser-typical Accept header, so it was never going to trigger whatever this was. The move was to go around the browser entirely: hit the page directly against a local/dev copy of the same code, and deliberately vary the request’s Accept header across a range of values, watching for whether the ad-loader’s own initialization call showed up in the response at all.

That test surfaced it almost immediately: a request carrying Accept: application/json — the kind of header sent by bots, link-preview crawlers, some monitoring/uptime tools, and a fair number of in-app webviews — got back a completely normal-looking HTML page, just with every single ad-related element silently absent. No error, no broken markup, nothing that would show up in a log as a failure. Just… nothing where the ads should have been.

What Was the Actual Bug?

The site’s ad-serving plugin registered a handful of distinct WordPress hooks — a header-injected loader script, theme ad-slot placeholders, in-content ad insertion, a content-recommendation widget’s meta tag, and a body class used to toggle ad-related styling — and all of them were gated behind a single shared guard function, something like is_frontend_html_request(). Reasonable in principle: don’t run ad logic on REST API calls, AJAX endpoints, or anything that isn’t a real page render.

The guard’s actual implementation, though, determined "is this JSON" by inspecting the incoming Accept header — and that check didn’t distinguish "this is a REST/AJAX endpoint that legitimately returns JSON" from "this is an ordinary HTML page request that merely happens to carry Accept: application/json." Those are very different things, but the header alone can’t tell them apart:

function is_frontend_html_request(): bool {
    $accept = $_SERVER['HTTP_ACCEPT'] ?? '';

    // Wrong: treats ANY request advertising JSON acceptance as non-HTML,
    // even a normal page request from a bot/prefetcher/webview that
    // happens to send this header.
    if ( str_contains( $accept, 'application/json' ) ) {
        return false;
    }

    return ! wp_doing_ajax() && ! defined( 'REST_REQUEST' );
}

A real REST API request and a bot’s HTML page request that happens to advertise JSON support both fail this check the same way — but only one of them should. The result: a completely valid HTML page, served with a 200, just missing every ad hook, because the shared gate silently returned false for a request that was never actually asking for JSON.

One ad slot survived, oddly enough — a Gutenberg block responsible for a single placement was registered through a different, unconditional path elsewhere in the plugin, and fired regardless. That left one orphaned JS snippet in the page with no corresponding loader script to receive it — a small, telling inconsistency that was itself a clue once someone noticed it: if the whole page were simply "ads off," that block wouldn’t have survived either.

Why Did One Bad Request Become Everyone’s Problem?

None of the above would have mattered much on its own — one bot gets an ad-less page, so what. What turned a narrow, low-traffic edge case into a real production incident was the platform’s full-page cache, which builds its cache key from the URL and doesn’t vary it on the Accept header.

The very first request to a given URL carrying that header — bot, crawler, monitoring probe, doesn’t matter which — got its ad-stripped HTML written into the cache under that URL’s normal cache key. Every subsequent visitor to that same URL, browser and all, was then served the cached, ad-less version, indistinguishable from a real broken deploy, until that cache entry expired or was manually purged. A single automated request with an unusual header was enough to silently degrade the page for every real visitor behind it.

Why Did the Same Bug Ship Twice?

The most humbling part of this one: the same bug reappeared, independently, in a later refactor. A subsequent version of the ad plugin decomposed the original monolithic gate function into several smaller, better-organized classes — genuinely better code, by most measures — but the new code preserved the same underlying Accept-header logic, unchanged, just relocated. The bug wasn’t in any one obviously-wrong line; it was in the semantics of a shared helper, and a structural refactor that didn’t specifically re-examine that helper’s correctness carried the defect forward intact, in a new shape.

That’s a useful, generalizable warning about refactors: reorganizing code without re-verifying the behavior of the logic you’re moving preserves bugs just as reliably as it preserves features.

How Do You Ship the Fix Without Shipping a Second Incident?

Fixing the guard function was the easy part — check the request path/route rather than a spoofable header to determine "is this actually a REST/AJAX endpoint," so a normal HTML request can never accidentally match:

function is_frontend_html_request(): bool {
    return ! wp_doing_ajax() && ! defined( 'REST_REQUEST' );
}

The part that’s easy to forget: fixing the code does not un-poison a cache that already has the broken version stored. Every previously-cached URL that had been hit by a triggering request was still going to serve the old, ad-less HTML until either its TTL expired naturally or someone explicitly purged it. The rollout here went through a staged, QA-gated pre-production tier first, and the production deploy was paired with an explicit full-page-cache purge immediately after — not a "let it expire naturally" approach, since "naturally" could mean hours of degraded pages depending on the URL’s traffic and TTL.

What’s the Generalizable Lesson Here?

  • "It’s random" is a real symptom, not a lack of one. Same URL, inconsistent behavior across requests, is the signature of a caching layer doing something request-dependent that your cache key doesn’t account for — chase that hypothesis early.
  • Any content-negotiation logic driven by a request header (Accept, Content-Type, User-Agent) needs to be paired with an explicit check of what the header is actually for in your app — a header value alone rarely proves intent, only what the client claims to accept.
  • A full-page cache that doesn’t vary its key on every dimension your response actually varies on will happily cache and redistribute a broken response to everyone behind the request that triggered it. If your response can differ based on a header, either key the cache on it or make sure the response genuinely can’t differ.
  • Deploying a fix for a cache-poisoning bug isn’t done when the code ships — it’s done when the poisoned cache entries are gone. Purge explicitly; don’t assume.
  • A refactor that relocates logic without re-verifying its correctness carries bugs forward as reliably as it carries features forward. "We rewrote this" is not the same claim as "we fixed this."