Media, Publishing & Streaming// diagnostic

Paying subscribers are hitting the wall, and only on the popular stories

In short

A response stored without an access dimension in its cache key is served to whoever asks next, so a subscriber's chance of getting the anonymous variant is roughly the object's cache hit ratio. That is why the complaint concentrates on the busiest stories and is invisible on quiet ones. The repair is a coarse access class in the key plus a client-side reveal.

Key takeaways

  • Popularity is exposure, not cause: a subscriber's odds of the wrong variant approximate the object's hit ratio.
  • The same missing cache dimension leaks the other way, storing a subscriber's page and serving it to anonymous readers.
  • Vary on the raw Cookie header is not a fix — consent and analytics cookies make the variant space effectively per-browser.
  • Two or three access classes in the key keeps the hit ratio; a per-reader key abandons caching entirely.
  • RFC 9111 says a stored response whose Vary is * never matches, so that header disables caching rather than tightening it.

The cause is almost always that the HTML was stored in a shared cache without anything in the cache key that says who it was rendered for. Once that has happened the object is served to whoever asks next, subscriber or not, and the probability that a given subscriber gets the anonymous copy is roughly the object's cache hit ratio. A story running at a 0.98 hit ratio will show the wall to almost every subscriber who opens it; the same code on a quiet archive page looks perfect, because that page is nearly always rendered fresh. Popularity is the exposure, not the fault.

If the disagreement is between surfaces rather than between requests — the app is happy and the website is not — that is a different investigation with different evidence, traced in the app says subscribed, the site says not. If access is wrong everywhere for one reader, look at whether the grant was ever updated at all: renewals that never reached the access system.

One hot URL, two locations, five minutes

  1. Pick a story currently trending and request it as an authenticated subscriber, with the real session cookie, from two networks far enough apart to land on different edge locations.
  2. Record the cache status header your CDN emits, the age header, and every Vary and Set-Cookie on the response. Repeat each request 5 times, because the first is often a miss and looks fine.
  3. Request the same URL anonymously and diff the two bodies. If they are byte-identical, one of them is wrong, and you now know which population is being served the other's page.
  4. Cross-check against traffic. Pull the top 50 URLs by request volume and the subscriber complaint counts for the same window. A correlation with hit ratio confirms the diagnosis; a flat distribution across popular and unpopular stories points somewhere else entirely.
  5. Check the reverse direction before closing the ticket. Request a cold, unpopular URL as a subscriber, then immediately request it anonymously. If the subscriber's full-text page comes back, the cache is also leaking paid content outwards, and that is the more expensive half of the same bug.

Five ways an anonymous variant reaches a paying reader

CauseWhat you will seeThe check
No access dimension in the cache keyByte-identical bodies for subscriber and anonymous requests, high age valuesDiff the two responses on a hot URL; inspect the key configuration for anything reader-derived
The auth cookie is stripped or ignored at the edgeOrigin logs show the request arriving without the session cookieCompare the Cookie header the browser sent against what the origin received
A client-side gate racing the renderCorrect after a refresh; the wall flashes and then disappears, or does notThrottle the network in dev tools and watch the order of the access call and the paint
A cached access decision outliving the entitlement changeWrong for a bounded period after a renewal or upgrade, then self-correctsRead the TTL on the decision cache and compare it against the complaint window
Vary set so broadly it destroyed the hit ratioNo wrong pages, but origin load and latency climbed after the last fixCount distinct variants stored per URL; check whether Vary names Cookie or is set to *
Ranked by how often each turns out to be the one

The last row is what the previous attempt at this bug usually left behind. It is worth separating clearly, because a team that ships it thinks the problem is solved and is instead measuring a different failure: the origin is now rendering everything.

The Vary header tells a cache which request headers form part of the secondary key: RFC 9111 requires a stored response not to be reused unless every request header named by Vary matches the original request's. Naming Cookie there is technically correct and practically ruinous, because the cookie header carries consent state, analytics identifiers and experiment buckets. Every distinct cookie string becomes its own stored variant, so the variant space approaches one entry per browser and the hit ratio approaches zero.

  • Vary: * is worse, not stricter. The specification is explicit that a stored response with a Vary of * never matches, which makes the response effectively uncacheable — MDN documents it in exactly those terms.
  • Authorization headers have their own rule. RFC 9111 forbids a shared cache from reusing a response to a request carrying Authorization unless the response explicitly permits it with a directive such as public, s-maxage or must-revalidate. Paywalls driven by bearer tokens hit this and read it as a CDN bug.
  • Cache-Control: private is not access control. It stops shared caches storing the response, which protects the reader's browser cache but throws away every edge benefit — and it does nothing about who the page was rendered for.
  • Normalise before you vary. Compute one derived header at the edge — an access class such as anonymous, registered or subscriber — from the session cookie, strip everything else from the key, and vary on that instead. Two or three variants per URL is a cache; ten thousand is a proxy.

A cache key with a reader in it is not a cache. It is an origin with extra hops and a monthly bill.

Cache a coarse class, then reveal the rest in the browser

The working shape has two halves. The edge serves a small number of cacheable variants keyed on a coarse access class, and the page completes itself in the browser with one call to an access endpoint. That endpoint answers for a specific reader, is never cached at the edge, and returns fast because it does nothing but resolve a grant — the design set out in one access check every surface can call.

  1. Decide the classes. Anonymous, registered and subscriber covers most estates. Institutional access adds a fourth only if its entitlements genuinely differ in what is rendered.
  2. Derive the class at the edge from the session cookie or token, write it into a single derived header, and make that header part of the key. Nothing else reader-derived belongs there.
  3. Render the gated variant with the full markup structure but without the paid body text. Never ship the full article inside hidden markup — a wall implemented in CSS is not a wall.
  4. Reveal in the browser after the access call returns, and keep its response short-lived so an entitlement change is visible within seconds rather than at the next TTL boundary.
  5. Add a standing synthetic check: an authenticated request to the current top stories from several regions, every few minutes, asserting that the subscriber body differs from the anonymous body. That is the monitor that would have caught this in the hour it shipped, and it is cheap automation of the kind we build under AI agents and automation.

What a correct cache key will still not fix

  • A grant that was never updated. If the renewal event never reached the entitlement store, the access endpoint will answer no with total confidence, and the cache is blameless.
  • Metered exemptions. Which sections and story ages are free is a counting policy that has to be evaluated somewhere, and folding it into the edge key multiplies your variants — the decisions set out in where the meter count lives and what it counts.
  • Content that must come down now. Access classes do not help you evict a story from every location under a legal clock; that needs targeted invalidation and a measured completion time, which is the machinery behind takedown intake, triage and the visible clock.
  • Stale article bodies. A subscriber seeing the right variant of an outdated story is a separate caching failure, traced in the story is updated but some readers see the old one.

This is one of the harder pieces of a publishing estate to hand to somebody else, because it fails only under real traffic and looks perfect in staging — worth weighing against the evaluation criteria in how to choose an AI development partner if you are bringing it in from outside. The rest of this silo sits under paywalls, subscriptions and entitlements, inside our media and publishing practice.

Frequently asked questions

Short answers to the follow-ups this page tends to raise.

Why do only popular articles show the paywall to logged-in subscribers?

Because popularity raises the cache hit ratio, and the hit ratio is the probability that a stored response is served instead of a fresh render. If the stored response has no access dimension in its key, a subscriber gets whatever variant was cached first, and on a busy story that is nearly every request. On a quiet story most requests miss the cache and get correctly rendered, so the bug hides.

Should I add Vary: Cookie to fix a paywall on a CDN?

No. Cookie headers carry consent, analytics and experiment values that differ per browser, so varying on them creates roughly one stored variant per reader and the cache stops working. Derive a coarse access class at the edge, put that single value in the key, and leave the raw cookie out of it.

Is it safe to render the full article and hide it behind the paywall in CSS?

No. Anything present in the delivered HTML is readable by anyone who opens the page source or disables styles, and it is also visible to scrapers. Render the gated variant without the paid body text and fetch the remainder after the access check returns.

How long should a cached access decision live?

Seconds rather than minutes, because a reader notices immediately when access is wrong and a renewal has to take effect while they are still in the payment flow. Keep the decision cache short and per-reader in their own session, and keep the shared edge cache to coarse classes, so no shared entry ever encodes an individual's entitlement.

  • paywall
  • cdn
  • caching
  • entitlements
// shipped work

The work behind this page

Builds from our portfolio that this page draws on.

Working on something in this space?

Tell us where you are in a sentence or two. We'll tell you honestly whether we're the right team, and what a sensible first slice of the work looks like.

Start the conversation