Elixir and the BEAM for AI systems
Phoenix's Default Cache-Control and the Check It Breaks
Plug sets cache-control on every response, so a check for a missing header never matches. How I fixed it on this site and what each page sends now.
Plug sets
cache-control: max-age=0, private, must-revalidate on every
response by default. You can see it on this site right now: a GET to
https://sublimecoding.com/ returns exactly that header, character for
character.
It matters once you write code that should set a cache policy only when nothing else has. The obvious check is “no cache header set,” and it never matches, because the header is never absent. What’s there is the default literal.
An earlier post covers scheduled posts with no database. This one is smaller. It’s a gotcha I hit while implementing a fix from my 2026-09-01 audit (PR #17, commit b456b27).
The check that never matched
The audit flagged that no HTML route set a cache-control header and
recommended public, max-age=300 on GET in the browser
pipeline. Implementing that meant a plug that adds a cache policy to a
response when no other code has set one.
Written as “if there’s no cache-control header, set mine,” that plug would do nothing. By the time the response goes out, Plug has already put its default in place. The header list is never empty, so the “nothing set yet” branch would be dead code.
The fix is to compare against the default literal. The header is either missing or it’s exactly what Plug writes on its own. Both mean nobody made a decision, so mine can go in. Any other value means some other code chose a policy on purpose, and I leave it alone.
What the site does now
The fix lives in the :browser pipeline in
lib/sublime_coding_web/router.ex, as
plug :cache_public. This is the code from the repo, wrapped
to fit:
@plug_default_cache "max-age=0, private, must-revalidate"
defp cache_public(conn, _opts) do
register_before_send(conn, fn conn ->
if conn.method == "GET" and conn.status == 200 and
conn.request_path != "/" and
conn.assigns[:flash] in [nil, %{}] and
get_resp_header(conn, "cache-control") in
[[], [@plug_default_cache]] do
put_resp_header(conn, "cache-control",
"private, max-age=300")
else
conn
end
end)
endThe last condition is the one this post is about.
get_resp_header/2 returns a list, so I match two shapes:
[] for no header at all, and a list holding the default
literal. The empty case covers a response where something deleted the
header. The literal is the case that actually happens.
Here is why each of the other conditions is there.
GET and 200 only. Every page on this site is deterministic. There’s no database and no per-user state, so a browser can reuse its copy for five minutes on back/forward and on repeat visits. Anything that isn’t a successful GET doesn’t get the policy.
private, not public. The
first visit’s response also sets the session cookie, which carries the
CSRF token. That response must never land in a shared cache. There’s a
second reason, and this one is my reading of the setup rather than
something I tested. The site sends a per-request CSP nonce from a plug
called CspNonce, and the header shows
script-src 'self' 'nonce-...'. If a shared cache served one
response to many visitors, they would all get the same nonce. The
audit’s own recommendation was public, max-age=300. It
shipped as private because of that cookie.
No flash. A response that carries a flash message is skipped.
Not on /. The homepage is the redirect
target for the contact form. A cached copy there would swallow the flash
that tells you the message went through. So / keeps the
default and gets refetched every time, on purpose.
Skip if the response already set its own policy. That’s the literal comparison again. A controller that chose a header keeps it.
What each page sends
I ran curl against the live site on 2026-11-05:
/returnsmax-age=0, private, must-revalidate. That’s the default, left in place deliberately./blogand/blog/<post>returnprivate, max-age=300. That’s my plug./rss.xmlreturnspublic, max-age=3600plus an ETag. The feed sets its own policy, andFeedControllercallsSublimeCodingWeb.ETag.send_with_etag. My plug sees a header that isn’t the default and steps aside.- Static files, like the og images and
/playbook.pdf, returnpublic, max-age=86400plus an etag. Those come fromcache_control_for_etagsonPlug.Staticinendpoint.ex, so they never go through the browser pipeline at all.
Checking your own app
- Run
curl -Iagainst your own pages and read what comes back. Do it for the homepage, a content page, a feed, and a static file. They probably won’t match. - Work out which layer sets each header. Router pipeline, controller, endpoint, and a reverse proxy in front can all write it.
- When a plug sets a policy only if nothing else did, compare against the default literal. Never compare against absence.
- Keep
privateon anything that sets a cookie or carries a nonce.
Read next: exit code 137 isn’t always out of memory, the other build-and-deploy bug from running this site.