Updated October 2026: Corrected what actionpack-page_caching and Rails’ counter_cache actually 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-revalidate and 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.

  1. Cache-Control:

    The main header. public lets shared caches (proxies, CDNs) store the response; private limits it to the user’s browser; no-store forbids storing it anywhere. max-age sets freshness in seconds for every cache, and s-maxage overrides it for shared caches only. In Rails, expires_in writes 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
    end
    

    Rails responses default to private, so nothing is shared until you opt in with public: true.

  2. 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 the ETag it has) or If-Modified-Since, and the server answers 304 Not Modified with an empty body if nothing changed. Rails does this with fresh_when and stale?:

    def show
      @article = Article.find(params[:id])
      fresh_when @article, public: true # renders 304 when the client's copy is still valid
    end
    

    Use stale? instead when the action does more work that you want to skip on a 304.

  3. stale-while-revalidate (and stale-if-error):

    These Cache-Control extensions 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 uses stale-while-revalidate as the default grace period; parts 2 and 4 show how.

  4. 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 separate s-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

  1. 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_caching gem, which writes each cached page as an .html file under public/ (for example /posts/hello becomes public/posts/hello.html). nginx or Apache then serves that file without touching Rails, and you remove it with expire_page when the content changes.

  2. Reverse Proxy Caching:

    Reverse proxy servers like nginx or Varnish sit in front of the application and store full responses according to their Cache-Control headers, 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.

  3. In-Application HTTP Cache:

    A Rack middleware such as Rack::Cache can play the reverse-proxy role inside the Ruby process, storing responses in memory, on disk or in Rails.cache (which can be backed by Redis). Part 3 uses it and deals with the CSRF-token problem it creates.

  4. 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.

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.

  1. 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 cache view helper. The request still reaches the application, so it saves rendering time, not the whole round trip.

  2. 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.

  3. Counter Caching:

    Rails’ counter_cache: true on a belongs_to association keeps the number of associated records in a column on the parent (for example books_count on authors), so author.books.size reads that column instead of running COUNT(*). It caches a count, not a page.

  4. 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.