The fastest request is the one your server never sees. Get Cache-Control right, use ETags without lying, cache pages for logged-in users safely, and purge by tag instead of by URL.
Application caching makes your code faster. HTTP caching makes your code unnecessary — the response is served by a CDN node near the user, and your server never learns the request happened. The mechanism is a handful of headers, and getting them slightly wrong is how you end up serving one customer's dashboard to another.
Between your view and the user there are four places a response can be stored:
Cache-Control.Cache-Control plus its own rules.The word that governs the dangerous ones is private versus public. private means "one user may store this"; public means "a shared cache may serve this to anyone". Mislabel a personalised page as public and a CDN will happily hand it to strangers.
Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400
max-age=300 — browsers may reuse for 5 minutes.s-maxage=3600 — shared caches may reuse for an hour. Overrides max-age for the CDN only.stale-while-revalidate=86400 — after expiry, serve the stale copy instantly and refresh in the background. This one is the single biggest perceived-speed win available, and it is one word.The split between max-age and s-maxage is the useful trick: short in the browser so users see changes quickly, long at the CDN so your server stays idle. A purge fixes the CDN instantly; you cannot purge a browser.
from django.views.decorators.cache import cache_control
@cache_control(public=True, max_age=300, s_maxage=3600,
stale_while_revalidate=86400)
def tutorial_detail(request, slug):
...
@cache_control(private=True, no_store=True)
def account_dashboard(request):
...
no-store is the strong one: do not write this anywhere, ever. Use it on authenticated pages, payment flows and anything with a token in it. no-cache does not mean that — it means "store it, but revalidate before reuse", which surprises people at exactly the wrong moment.
Vary tells shared caches which request headers change the response. Get it wrong in either direction and you have a bug.
Vary: Accept-Encoding, Accept-Language, Cookie
Vary: Cookie on a page that reads the session → the first visitor's personalised page is served to everyone. This is the classic catastrophic caching bug.Vary: Cookie on a public page → every distinct cookie set becomes its own cache entry, so your hit rate collapses to roughly zero. Analytics cookies alone guarantee it.The resolution is architectural: keep public pages genuinely cookie-free. Serve anonymous visitors pages with no session cookie at all, and fetch the personalised fragment — cart count, username — with a small separate request that is never cached.
<span id="cart-count" hx-get="/cart/count/" hx-trigger="load"></span>
Now the page itself is one cache entry shared by every anonymous visitor, and the personal bit costs one tiny uncached request.
Freshness lifetimes avoid requests. ETags make the unavoidable ones cheap: the client sends the tag it holds, and you answer 304 Not Modified with an empty body.
from django.views.decorators.http import etag, last_modified
def tutorial_etag(request, slug):
row = Tutorial.objects.filter(slug=slug).values("updated_at", "views").first()
if not row:
return None
return f'"{row["updated_at"].timestamp()}"'
@etag(tutorial_etag)
def tutorial_detail(request, slug):
...
Two rules. The tag function must be cheaper than rendering — one indexed query on a couple of columns, not the same work as the view. And the tag must change whenever anything visible changes: template edits and CSS bumps included. Hashing your deploy SHA into it costs nothing and prevents the "the site is stale after deploy" mystery.
Django's ConditionalGetMiddleware will generate ETags from response bodies automatically. That saves bandwidth but not rendering time — the view already ran. Explicit tags skip the work; automatic tags only skip the transfer.
You edit one tutorial. Which URLs are now wrong? The detail page, the category listing, the tag pages, the home page, the sitemap, the feed. Enumerating them is a losing game.
Instead, tag responses as you build them and purge by tag:
class SurrogateKeyMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.surrogate_keys = set()
response = self.get_response(request)
if request.surrogate_keys and response.status_code == 200:
response["Surrogate-Key"] = " ".join(sorted(request.surrogate_keys))
return response
def tutorial_detail(request, slug):
tutorial = get_object_or_404(Tutorial, slug=slug)
request.surrogate_keys |= {
f"tutorial-{tutorial.pk}",
f"category-{tutorial.category_id}",
}
...
Then a single signal invalidates every page that mentioned the object, wherever it appeared:
@receiver(post_save, sender=Tutorial)
def purge_tutorial(sender, instance, **kwargs):
purge_surrogate_keys([f"tutorial-{instance.pk}",
f"category-{instance.category_id}"])
Not every CDN supports surrogate keys, and the header name varies. Where it is unavailable, the fallback is short s-maxage plus stale-while-revalidate — less precise, but it degrades gracefully instead of serving week-old content.
Hashed filenames make assets immutable, and immutable assets can be cached effectively forever:
STORAGES = {
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
}
location /static/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
immutable tells the browser not even to revalidate on reload. Safe only because the filename changes when the content does — which is exactly what the manifest storage guarantees. Never put immutable on an unhashed path.
The received wisdom is "you cannot cache authenticated pages". You can cache most of them, if you separate the layers:
And whatever you choose: on login, logout and any state change, send Cache-Control: no-store and change the session cookie. The bug that leaks a logged-in page to the next visitor almost always starts with a cached redirect.
curl -sI https://example.com/tutorials/some-slug/ | grep -iE 'cache|vary|age|etag'
What to look for:
Age greater than zero — the CDN is serving from cache. If it is always 0, you are not caching at all.X-Cache style header showing HIT or MISS — request the same URL twice and expect the second to hit.Vary containing exactly what you intended and nothing more.Set-Cookie on a page you believe is public — that is your hit rate leaking away, and most CDNs refuse to cache such a response at all.Measure hit rate per route rather than overall. A 90% average can hide the one expensive page that never caches — and that page is your load.
private, no-store.public, short max-age, long s-maxage, plus stale-while-revalidate.Vary set deliberately — never accidentally including Cookie.immutable.Done properly, most of your traffic stops arriving. The server you were about to upgrade turns out to be idle — which is the cheapest performance work available, and it is mostly headers.