Updated October 2026: Corrected what
actionpack-page_cachingand Rails’counter_cacheactually do, separated true page caching from related techniques (fragment, low-level and service-worker caching), and added HTTP caching basics:Cache-Control, ETag/conditional GET,stale-while-revalidateand CDN HTML caching with purge.
I. What is Page Caching?
Page caching is a technique used to improve the performance of web applications by storing the entire HTML output of a page and serving it directly from the cache for subsequent requests. This eliminates the need to generate the page dynamically on each request, reducing server load and improving response times. Page caching is particularly effective for static or semi-static pages that don’t require frequent updates.
The catch is that a cached page is shared by every visitor who requests it. Anything personal — a “Hi, Alice” header, a cart count, a CSRF token tied to one session — must not end up in it, or one user will see another user’s page. Most of this series is about where to put the cache and how to keep that boundary safe.
II. HTTP Caching Basics
Every page cache in this series — a reverse proxy, a CDN, the browser — decides what to store and for how long by reading standard HTTP headers. It is worth getting these right first.
Cache-Control:The main header.
publiclets shared caches (proxies, CDNs) store the response;privatelimits it to the user’s browser;no-storeforbids storing it anywhere.max-agesets freshness in seconds for every cache, ands-maxageoverrides it for shared caches only. In Rails,expires_inwrites this header for you:class PagesController < ApplicationController def show # Cache-Control: max-age=3600, public, stale-while-revalidate=60 expires_in 1.hour, public: true, stale_while_revalidate: 60.seconds end endRails responses default to
private, so nothing is shared until you opt in withpublic: true.ETag and conditional GET:
When a cached copy expires, the cache does not have to download the whole page again. It sends
If-None-Match(with theETagit has) orIf-Modified-Since, and the server answers304 Not Modifiedwith an empty body if nothing changed. Rails does this withfresh_whenandstale?:def show @article = Article.find(params[:id]) fresh_when @article, public: true # renders 304 when the client's copy is still valid endUse
stale?instead when the action does more work that you want to skip on a304.stale-while-revalidate(andstale-if-error):These
Cache-Controlextensions let a cache keep serving the expired copy for a few more seconds while it fetches a fresh one in the background (or while the origin is failing). Visitors never wait for the regeneration. nginx honours both, and Varnish usesstale-while-revalidateas the default grace period; parts 2 and 4 show how.CDN HTML caching with purge:
CDNs can cache HTML at the edge, not just images and CSS, as long as the origin marks the page cacheable for shared caches (
public, often with a separates-maxage) and the page contains nothing per-user. Since an edge copy can live for hours, pair it with the CDN’s purge API: when content changes, purge the affected URLs (or a tag/prefix) instead of waiting for the TTL to run out.
III. Where to Cache a Whole Page
Framework Page Caching (Static Files):
Some frameworks can write the rendered page to disk so the web server serves it as a plain static file. Rails removed page caching from core in Rails 4.0; it lives on in the
actionpack-page_cachinggem, which writes each cached page as an.htmlfile underpublic/(for example/posts/hellobecomespublic/posts/hello.html). nginx or Apache then serves that file without touching Rails, and you remove it withexpire_pagewhen the content changes.Reverse Proxy Caching:
Reverse proxy servers like nginx or Varnish sit in front of the application and store full responses according to their
Cache-Controlheaders, serving repeat requests without hitting the backend. This offloads the caching responsibility from the application server and improves scalability and performance. Parts 2 and 4 of this series cover Varnish and nginx.In-Application HTTP Cache:
A Rack middleware such as
Rack::Cachecan play the reverse-proxy role inside the Ruby process, storing responses in memory, on disk or inRails.cache(which can be backed by Redis). Part 3 uses it and deals with the CSRF-token problem it creates.Content Delivery Networks (CDNs):
CDNs cache static assets such as images, CSS files, and JavaScript files on distributed edge servers located closer to users. Many can also cache entire HTML pages and deliver them quickly to users worldwide, with the purge workflow described above.
IV. Related Techniques (Not Page Caching)
These cache smaller pieces than a full page. They are often combined with page caching, or used instead of it when a page is too personalised to share.
Fragment Caching:
Fragment caching stores specific parts of a page that are expensive to render (a product card, a sidebar) while the rest of the page is still rendered per request. In Rails this is the
cacheview helper. The request still reaches the application, so it saves rendering time, not the whole round trip.Low-Level Caching:
Caching the result of an expensive computation or query in a cache store such as Redis, Memcached or the database-backed Solid Cache, e.g. with
Rails.cache.fetch. Active Record also keeps a per-request SQL query cache, so the same query issued twice within one action only hits the database once.Counter Caching:
Rails’
counter_cache: trueon abelongs_toassociation keeps the number of associated records in a column on the parent (for examplebooks_countonauthors), soauthor.books.sizereads that column instead of runningCOUNT(*). It caches a count, not a page.Service Worker Caching:
Service workers are scripts that run in the background of a web application and can intercept network requests. They can cache static assets, API responses, and even HTML in the user’s browser to provide offline access and faster repeat visits. The cache is per browser, so it complements a shared page cache rather than replacing it.
V. Conclusion
Page caching is a valuable technique for improving the performance and scalability of web applications by serving pre-generated HTML pages directly from cache. Start with correct HTTP headers (Cache-Control, ETags, stale-while-revalidate), then choose where the shared copy lives: static files written by the framework, a reverse proxy, an in-app HTTP cache, or a CDN with a purge workflow. Fragment, low-level, counter and service-worker caching are useful companions, but they cache pieces, not pages. Whatever you pick, keep per-user data out of the shared copy.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.