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)
end

The 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:

  • / returns max-age=0, private, must-revalidate. That’s the default, left in place deliberately.
  • /blog and /blog/<post> return private, max-age=300. That’s my plug.
  • /rss.xml returns public, max-age=3600 plus an ETag. The feed sets its own policy, and FeedController calls SublimeCodingWeb.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, return public, max-age=86400 plus an etag. Those come from cache_control_for_etags on Plug.Static in endpoint.ex, so they never go through the browser pipeline at all.

Checking your own app

  1. Run curl -I against 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.
  2. Work out which layer sets each header. Router pipeline, controller, endpoint, and a reverse proxy in front can all write it.
  3. When a plug sets a policy only if nothing else did, compare against the default literal. Never compare against absence.
  4. Keep private on 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.