System Design

Patterns

Denylist Push against Cache Purge

Two ways to stop serving something you already distributed. Purging asks every cache to forget; pushing a denylist asks every serving node to refuse. They fail differently, and only one of them has a bound you can promise.

The shape

You copied something outward to make reads fast: into edge caches, into replicas, into clients. Now it has to stop being served. A post was deleted, a token was revoked, a licence expired, a court order arrived.

There are two ways to stop serving data you have already copied outward, and they work in opposite directions.

Cache purge takes the copy away. You tell every cache holding the item to drop it. The next request finds nothing there and goes back to your origin servers, which no longer have it either, so nothing is served. Purge is cheap when the item is in a few places, and it only works if you reach every one of them.

Denylist push leaves the copy where it is and takes away the permission. You delete nothing. Instead you send every serving node a short list of identifiers it must not serve, and each node checks that list before answering a request. The item is still sitting in the cache. The node just refuses to hand it over.

Purge removes the data, a denylist removes the permission. That sounds like a small difference and it decides how each one fails.

A purge is a one-time message. A node that never receives it keeps serving the item forever. And once the message has been sent, nothing is left on any node recording what was meant to disappear. So finding the node that missed one means asking every node about every item you have ever purged.

A denylist is not a message, it is state that every node holds and can report. Give the list a version number and each node tells you which version it has. A node that misses an update is still serving the item, so the damage is identical, but it is now showing version 41 while everything else shows 42. Same failure, and this time you can see it.

The same choice shows up under other names. Revoking a JWT before it expires. Un-publishing a CDN asset. Pulling a bad package version, or a video, across a global network. All the same decision.

The moves

Both have to reach every serving node before anything changes, and they get there differently.

Both rows show the same three edges and the same unreachable one. The only thing that differs is whether that edge can tell you it is behind.

Purge propagation is a fan-out you do not control. A global CDN purge usually finishes in seconds, sometimes minutes, and the slowest node to reach is what sets that number. The API usually returns as soon as the request is accepted rather than when propagation completes, so the acknowledgement you get is not the guarantee you want.

Denylist propagation is a pull with a version. Nodes fetch the current list on an interval, and every published list carries a version number and the time it was published. Each node reports the version it holds, and one subtraction tells you how stale that node is: the time now, minus the time its version was published. The answer is in seconds, and it is how far behind that node's copy of the denylist has fallen. The worst one in the fleet is a single number you can alert on and put in a runbook. You can say "no node serves content more than 30 seconds after revocation" and defend it, which you cannot say about purge.

The denylist has to stay small, and that constrains what it can express. It works for a bounded set of recently revoked identifiers. It does not work as a permanent record of everything ever deleted, because every serving node holds the whole thing. The standard resolution is a hybrid: push to the denylist immediately for fast, bounded revocation, purge and delete underneath for permanence, then expire entries from the denylist once the purge is confirmed and no cache could still hold the item.

That hybrid is what most real systems run, and saying out loud why both halves are needed is what the question is testing.

How to tell you have this problem

You have any cache with a long TTL in front of data whose permissions can change. Long TTLs are chosen for hit ratio, and every one of them is a window during which a revocation is not in effect.

Your deletion story ends at the database. If "delete" means removing a row and nobody has written down what happens to the copies at the edge and on devices, the copies are still being served and nobody has measured for how long.

You cannot state a revocation bound. The test question: how long after pressing delete can the content still be served, at p99, and what evidence supports that number? If the answer is "the purge should handle it", you have no bound and nobody has checked.

You have already discovered that immutable caching works against you here. Immutable, long-lived URLs are what make a cache work, and they are exactly what makes revocation hard. You cannot have one without the other, so say which side you took and what it cost you.

What it costs

Neither mechanism reaches a client that already has the bytes. A browser, an app cache, or a downloaded file is beyond any purge or denylist, and no amount of either changes that. If someone actually needs the content to become unreachable, the change has to happen before the bytes ever go out. Two ways to do that. Short-lived signed URLs, which stop working a few minutes after you hand them out, so an old copy expires on its own. Or content you never serve unencrypted, so a copy someone kept is useless without a key you can take away. Both lower your cache hit ratio, and a lower hit ratio means more traffic at your origin servers and a bigger bill. Pick one at design time, because neither can be added afterwards for bytes that are already out.

A denylist is a check on the read path. It runs on every request, so it must be a memory lookup, not a network call. That means it is a per-node data structure to keep fresh, size, and monitor, which is a small distributed system of its own.

Purge is often rate-limited and sometimes billed. Providers limit purge rates, especially for wildcard or prefix purges. A mass deletion of a million objects is not one operation; it is a job with a throughput ceiling that may take longer than the compliance window you are working to.

Staleness monitoring is the part people skip. A denylist gives you a bound only if you actually measure the oldest version in the fleet. Without that measurement it has the same silent failure mode as purge, and you have taken on the complexity without buying the guarantee.

Where it shows up in the course

articlethe form it takes there
Caching Strategies and Invalidationthe three ways to invalidate, and the case where the reason is revocation rather than change
CDNs and Edge Computepurge as an invalidation strategy, and why coupling it to the write path is a global fan-out
Instagramdeletion against a one-year immutable max-age, where the purge reaches the edge but not the device, so the answer has to be that the bytes stay reachable
URL Shortenera phishing takedown against 300 caches holding the mapping forever, where a purge fails silently and a denylist version can be alarmed on
YouTubea takedown against year-long segment caching, where purge alone cannot give you a bound you can state

Getting it wrong

Treating a purge acknowledgement as completion. The API confirms acceptance, not propagation. If you need a bound, measure propagation separately.

Calling a delete done when only your own copy is gone. Deleting the row on your origin servers does not touch the copies at the edge, and it does not touch the one on somebody's phone. Say how long the content can still be served after a delete, and say plainly that phones and browsers are not covered by that number at all.

Letting the denylist grow without bound. It lives on every serving node. Entries must expire once the underlying purge and delete are confirmed, or it eventually costs memory and a lookup on every single request.

Checking the denylist over the network. A per-request round trip on the hot path defeats the purpose of the cache you were protecting. It has to be local memory.

Not monitoring denylist version age. Without that number the mechanism has purge's silent failure and none of its simplicity.

Assuming short TTLs are a cheaper answer. They are an answer, but the cost is a permanently lower hit ratio applied to all content, in exchange for faster revocation of the tiny fraction that gets revoked. Do that arithmetic before choosing it.

Spot a mistake, or something unclear? . Or get in touch.

Article

Send feedback

Thank you for helping. We read every report and fix what is wrong.

How do you feel about oneby7?

Sign in to oneby7

Keep your progress, your streak and your code, on any device.

New here? This creates your account too.