SSR Caching: Prerender Caching and Fragment Caching
Available with React on Rails Pro. Free or very low cost for startups and small companies. Upgrade or licensing details →
Server-side rendering (SSR) is expensive. Every render evaluates JavaScript, assembles props from the database, serializes them to JSON, and produces HTML. React on Rails Pro provides two levels of caching that avoid repeating this work on every request. Both solve the same core problem — eliminating redundant SSR — but they operate at different layers and offer different tradeoffs.
| Prerender Caching | Fragment Caching | |
|---|---|---|
| What it caches | The JavaScript evaluation result (the SSR output for a given set of props) | The entire rendered fragment including props assembly, serialization, and SSR |
| Setup effort | One config line — config.prerender_caching = true | Requires choosing cache keys and passing props as a block |
| Cache key | Automatic: hash of server bundle + JavaScript code to evaluate | You define it: any combination of ActiveRecord models, timestamps, locale, URL, etc. |
| Skips prop evaluation? | No — props are still computed, serialized, and sent to the JS engine; only the JS execution result is cached | Yes — when the cache hits, the props block is never called, saving database queries and serialization |
| Best for | Quick win, especially when you want to avoid JS evaluation overhead without changing view code | Maximum performance, especially for pages with expensive prop assembly (database queries, API calls) |
| Start here? | Yes — turn it on first and measure the impact | Add it next for your highest-traffic or most expensive components |
Recommendation: Start with prerender caching (one line of config). Once you see the benefit, add fragment caching to your most expensive components for the biggest additional gains. Fragment caching subsumes prerender caching — when a fragment cache hits, prerender caching is never consulted because the entire rendered output is already stored.
Consult the Rails Guide on Caching for details on:
- Cache Stores and Configuration
- Determination of Cache Keys
- Caching in Development: To toggle caching in development, run
rails dev:cache.
Tracing
If tracing is turned on in your config/initializers/react_on_rails_pro.rb, you'll see timing log messages that begin with [ReactOnRailsPro:1234]: exec_server_render_js where 1234 is the process id and exec_server_render_js could be a different method being traced.
- exec_server_render_js: Timing of server rendering, which may have the prerender_caching turned on.
- cached_react_component and cached_react_component_hash: Timing of the cached view helper which may be calling server rendering.
Here's a sample. Note the second request:
Started GET "/server_side_redux_app_cached" for ::1 at 2018-05-24 22:40:13 -1000
[ReactOnRailsPro:63422] exec_server_render_js: ReduxApp, 230.7ms
[ReactOnRailsPro:63422] cached_react_component: ReduxApp, 2483.8ms
Completed 200 OK in 3613ms (Views: 3407.5ms | ActiveRecord: 0.0ms)
Started GET "/server_side_redux_app_cached" for ::1 at 2018-05-24 22:40:36 -1000
Processing by PagesController#server_side_redux_app_cached as HTML
Rendering pages/server_side_redux_app_cached.html.erb within layouts/application
[ReactOnRailsPro:63422] cached_react_component: ReduxApp, 1.1ms
Completed 200 OK in 19ms (Views: 16.4ms | ActiveRecord: 0.0ms)
Level 1: Prerender Caching
Prerender caching is the simplest way to speed up SSR. It caches the result of JavaScript evaluation so that identical rendering calls return instantly from the Rails cache instead of re-executing JavaScript.
How it works
When a react_component call triggers SSR, React on Rails Pro computes a cache key from:
- A hash of the server bundle
- The JavaScript code to evaluate (which includes the serialized props)
If the cache contains an entry for that key, the cached HTML is returned without calling JavaScript. If not, the JS is evaluated, the result is cached, and then returned.
Setup
One line in config/initializers/react_on_rails_pro.rb:
config.prerender_caching = true
That's it. No view code changes required.
When to use it
- As a quick first step — turn it on and measure the impact before investing in fragment caching
- When your components are stateless (same props always produce the same output)
- When you want to reduce load on the JavaScript evaluation engine (ExecJS or Node Renderer)
When NOT to use it
- If you're already using fragment caching for most components, prerender caching adds cache entries without additional benefit for those components (increasing the likelihood of premature cache ejection)
- If your server-side JavaScript depends on external state (AJAX calls, GraphQL) that makes rendering non-idempotent
Diagnostics
If you're using react_component_hash, you'll get 2 extra keys returned:
- RORP_CACHE_KEY: the prerender cache key
- RORP_CACHE_HIT: whether or not there was a cache hit.
It can be useful to log these to the rendered HTML page to debug caching issues.
Level 2: Fragment Caching
Fragment caching is the advanced option that delivers the biggest performance gains. It caches the entire rendered output — including the cost of assembling props from the database — so that on a cache hit, no database queries, no JSON serialization, and no JavaScript evaluation occur. See also the Fragment Caching overview.
This is very similar to Rails fragment caching:
Fragment Caching allows a fragment of view logic to be wrapped in a cache block and served out of the cache store when the next request comes in.
If you're already familiar with Rails fragment caching, the React on Rails implementation should feel familiar. The most important parts to consider are:
- Determining the optimal cache keys that minimize any cost such as database queries.
- Clearing the Rails.cache on some deployments.
Why Use Fragment Caching?
- Next to caching at the controller or HTTP level, this is the fastest type of caching.
- The additional complexity to add this with React on Rails Pro is minimal.
- The performance gains can be huge.
- The load on your Rails server can be far lessened.
Why Not Use Fragment Caching?
- It's tricky to get all the right cache keys. You have to consider any values that can change and cause the rendering to change. See the Rails docs for cache keys
- Testing is a bit tricky or just not done for fragment caching.
- Some deployments require you to clear caches.
Considerations for Determining Your Cache Key
- Consult the Rails docs for cache keys for help with cache key definitions.
- If your React code depends on any values from the Rails Context, such as the
localeor the URLlocation, then be sure to include such values in your cache key. In other words, if you are using some JavaScript such asreact-routerthat depends on your URL, or on a call totoLocaleString(locale), then be sure to include such values in your cache key. To find the values that React on Rails uses, use some code like this:
the_rails_context = rails_context
i18nLocale = the_rails_context[:i18nLocale]
location = the_rails_context[:location]
If you are calling rails_context from your controller method, then prefix it like this: helpers.rails_context so long as you have react_on_rails > 11.2.2. If less than that, call helpers.send(:rails_context, server_side: true)
If performance is particularly sensitive, consult the view helper definition for rails_context. For example, you can save the cost of calculating the rails_context by directly getting a value:
i18nLocale = I18n.locale
How: API
Here is the doc for helpers cached_react_component and cached_react_component_hash. Consult the view helpers API docs for the non-cached analogies react_component and react_component_hash. These docs only show the differences.
# Provide caching support for react_component in a manner akin to Rails fragment caching.
# All the same options as react_component apply with the following difference:
#
# 1. You must pass the props as a block. This is so that the evaluation of the props is not done
# if the cache can be used.
# 2. Provide the cache_key option
# cache_key: String or Array (or Proc returning a String or Array) containing your cache keys.
# If prerender is set to true, the server bundle digest will be included in the cache key.
# The cache_key value is the same as used for conventional Rails fragment caching.
# 3. Optionally provide the `:cache_options` key with a value of a hash including as
# :compress, :expires_in, :race_condition_ttl as documented in the Rails Guides
# 4. Provide boolean values for `:if` or `:unless` to conditionally use caching.
You can find the :cache_options documented in the Rails docs for ActiveSupport cache store.
API Usage examples
The fragment caching for react_component:
<%= cached_react_component("App", cache_key: [@user, @post], prerender: true) do
some_slow_method_that_returns_props
end %>
Suppose you only want to cache when current_user.nil?. Use the :if option (unless: is analogous):
<%= cached_react_component("App", cache_key: [@user, @post], prerender: true, if: current_user.nil?) do
some_slow_method_that_returns_props
end %>
And a fragment caching version for the react_component_hash:
<% result = cached_react_component_hash("ReactHelmetApp", cache_key: [@user, @post],
id: "react-helmet-0") do
some_slow_method_that_returns_props
end %>
<% content_for :title do %>
<%= result['title'] %>
<% end %>
<%= result["componentHtml"] %>
<% printable_cache_key = ReactOnRailsPro::Utils.printable_cache_key(result[:RORP_CACHE_KEY]) %>
<!-- <%= "CACHE_HIT: #{result[:RORP_CACHE_HIT]}, RORP_CACHE_KEY: #{printable_cache_key}" %> -->
Note in the above example, React on Rails Pro returns both the raw cache key and whether or not there was a cache hit.
Your JavaScript Bundles and Cache Keys
When doing fragment caching of server rendering with React on Rails Pro, the cache key must reflect
your React. This is analogous to how Rails puts an MD5 hash of your views in
the cache key so that if the views change, then your cache is busted. In the case
of React code, if your React code changes, then your bundle name will
change if you are doing the inclusion of a hash in the name. However, if you are
using a separate webpack configuration to generate the server bundle file,
then you must not include the hash in the output filename or else you will
have a race condition overwriting your manifest.json. Regardless of which
case you have, React on Rails handles it.
Tag-Based Revalidation
Cache keys answer "is this entry still current?" at read time. Tags answer a different question: "delete everything that depends on this data, right now." With cache_tags:, you attach declarative invalidation handles to fragment-cached components and bust them all with one call — the React on Rails Pro analog of Next.js revalidateTag.
<%= cached_react_component("PostShow",
cache_key: [@post, I18n.locale],
cache_tags: [@post, @post.author],
cache_options: { expires_in: 12.hours }) do
{ post: @post.to_props }
end %>
# Anywhere in Ruby — controller, job, service object, console:
ReactOnRailsPro.revalidate_tag(post) # => number of cache entries deleted
ReactOnRailsPro.revalidate_tags(post, post.author)
cache_tags: is accepted by all four cached helpers — cached_react_component, cached_react_component_hash, cached_stream_react_component, and cached_async_react_component — and is purely additive: cache_key: semantics are unchanged, and cache_key: is still required.
Tag forms and normalization
A tag can be:
- a String — passed through unchanged (
"post:42") - a Symbol or Numeric — stringified via
.to_s(:featured->"featured",42->"42") - an object responding to
#cache_key(any ActiveRecord model) — ActiveRecord-style records normalize to the stable identityposts/42(equal to the version-lessrecord.cache_key, and stable even whencache_versioningis disabled), so the tag stays valid as the record changes; other objects pass their#cache_keythrough. Objects with bothmodel_nameandidalways resolve tocollection/id; pass an explicit String tag if you want a different key. - a Proc (arity 0) returning any accepted form
- an Array of any mix of the above
Tags that normalize to blank raise ReactOnRailsPro::Error. Tag coarsely (post:42, tenant:7, posts:index) — never per-user or per-request; see the index bounds below.
Revalidating from the model layer
Include the Revalidates concern so the model that owns the data also owns cache invalidation. It runs in after_commit, so it never fires for a rolled-back transaction and fires only after the new data is visible to the re-rendering request:
class Post < ApplicationRecord
include ReactOnRailsPro::Cache::Revalidates
revalidates_react_cache # default tag: record.cache_key, e.g. "posts/42"
# or custom / additional tags:
# revalidates_react_cache { |post| ["post:#{post.id}", "author:#{post.author_id}"] }
end
This covers create/update/destroy and touch — so belongs_to ..., touch: true composes for free: touching the parent fires the parent's revalidation. The standard Rails callback caveat applies: update_column, update_all, delete_all, and other callback-skipping writes do not trigger revalidation; call ReactOnRailsPro.revalidate_tags(record) yourself after such writes.
One more caveat for custom tag blocks: the block runs in after_commit and sees only the record's new values. If a custom tag derives from a mutable attribute (e.g. "author:#{post.author_id}" and a post moves to a different author), the old grouping's entries are not revalidated — they expire via expires_in. Prefer tags derived from the record's own identity, or revalidate the old grouping explicitly (previous_changes in after_commit has the prior value).
How it works, and the contract
On every tagged cache write, the final cache key is appended to a per-tag index entry in Rails.cache (keyed by a SHA-256 digest under rorp:tag:v1: so long tag names and whitespace do not violate cache-store key limits). revalidate_tag reads the index, deletes the recorded entries with delete_multi, then deletes the index entry. A missing index (never-written tag, evicted entry, :null_store) means "nothing to revalidate" — never an error.
Tag revalidation is best-effort; correctness is bounded by expires_in. ActiveSupport::Cache has no atomic set-append, so the index append is a read-modify-write: two processes caching different entries under the same tag at the same moment can race, and one entry can be lost from the index (the cached data itself is never lost). A lost index entry simply survives revalidate_tag and expires via its own expires_in. The same applies when the index entry itself is LRU-evicted. Therefore:
- Always set
expires_in(orexpires_at) on tagged entries. It is the upper bound on how long a missed invalidation can serve stale HTML. In development, React on Rails Pro logs a warning whencache_tags:is used without an expiry. - Use a shared cache store in production — Redis or Memcached. With
:memory_storethe index is per-process, sorevalidate_tagin one process cannot see entries written by another; with:null_storetags are inert. - Keep cache deletion failures bounded by expiry.
revalidate_tagclears the tag index before deleting the indexed entries to reduce re-registration races. If the cache store raises during deletion, any surviving entries are orphaned from that tag and only expire via their ownexpires_in. - Custom cache stores must honor
namespace: nilindelete_multianddelete. The tag index records the fully namespaced logical keys that Rails wrote, then suppresses the store default namespace at delete time. Stores that ignoreoptions[:namespace]can silently miss tag-revalidation deletes.
Two config knobs bound the index (defaults shown):
ReactOnRailsPro.configure do |config|
config.cache_tag_index_expires_in = 7.days # index TTL ceiling for entries without expires_in
config.cache_tag_index_max_keys = 5_000 # keys recorded per tag; oldest dropped beyond this
end
When a tagged entry has expires_in, the index entry's TTL automatically covers it (plus slack). When a tag exceeds the per-tag key cap, the oldest keys are dropped with a logged warning — those entries fall back to plain TTL expiration.
Note that tags solve data-driven invalidation only. Deploy invalidation is already handled by the server-bundle digest in the cache key (see Cache Warming) — a deploy cold-starts prerendered fragment caches regardless of tags.
Next.js mapping
| Next.js 16 (Cache Components) | React on Rails Pro |
|---|---|
'use cache' on a function/component | cached_react_component / cached_react_component_hash / streaming and async variants |
cacheTag('post-42') inside the cached scope | cache_tags: ["post:42"] helper option |
revalidateTag('post-42') in a Server Action | ReactOnRailsPro.revalidate_tag("post:42") — anywhere in Ruby, incl. after_commit |
Manually wiring revalidateTag into every mutation | include ReactOnRailsPro::Cache::Revalidates — invalidation rides the AR transaction |
cacheLife profiles | cache_options: { expires_in: ... } |
revalidateTag(tag, 'max') / SWR profiles | Not yet supported (stale-while-revalidate is a possible future addition) |
Like Next.js's default revalidateTag, revalidation deletes: the next request re-renders and re-registers. There is no background refresh.
Cache Warming
Fragment cache keys include the server bundle digest, which means every deploy creates new cache keys. This is correct — rendered output must match the current bundle — but it means every deploy starts with a cold cache. Under live traffic, this creates a synchronized storm of cache misses: every user request triggers full SSR, database queries for props assembly, and JS evaluation simultaneously.
The solution is cache warming: rendering your highest-traffic pages in the background immediately after deploy, before real users hit those pages.
The pattern
- Deploy new code
- Identify the pages that matter most (by recent traffic)
- Render those pages in background jobs through the normal view path
- The existing
cached_react_componenthelpers fill the cache naturally - Live traffic hits warm caches instead of triggering cold rebuilds
# app/jobs/warm_page_job.rb
class WarmPageJob
include Sidekiq::Job
sidekiq_options queue: "cache_warming", retry: 3
def perform(page_id, release_version)
return unless ReleaseRegistry.current_version == release_version
page = Page.includes(:restaurant, :menu).find(page_id)
ApplicationController.renderer.render(
template: "restaurants/show",
assigns: { restaurant: page.restaurant, menu: page.menu }
)
end
end
The job does not write full-page HTML into a custom cache key. It simply runs the normal render path so the fragment caches embedded in the page are populated before real users arrive.
Why warming matters
The real bottleneck during cold-cache rebuilds is often database contention, not app-server CPU. Many pages go cold simultaneously, each render triggers database queries, and concurrency spikes overwhelm the primary database. Extra app servers don't fix a saturated database.
Key techniques for production cache warming:
- Prioritize by traffic: Warm the top 5,000 most-visited pages first. A page with 10,000 daily visits matters more than one with 100.
- Use read replicas: Route warming queries to a replica to protect the primary database.
- Rate limit: Cap warming concurrency to what the database can handle. Sidekiq rate limiters prevent overwhelming the rendering system.
- Stampede prevention: Use Redis
SET NXlocks to ensure only one worker renders a given page at a time. - Jittered expiration: Use
expires_in: 24.hours + rand(0..3600)instead of fixed TTLs to prevent synchronized mass expiration.
Real-world impact
At Popmenu (a ShakaCode client running React on Rails Pro), cache warming across 37 Sidekiq dynos processing 2,000–8,000 pages per minute produced:
- Average TTFB dropped from 320ms to 65ms (-80%)
- Server CPU during peak hours dropped 45%
- Database connections dropped 35% (fewer concurrent renders)
For more details on the full cache warming architecture including stampede prevention, event-driven warming, and monitoring, contact justin@shakacode.com for consulting on production cache warming strategies.
Confirming and Debugging Cache Keys
Cache key composition can be confirmed in development mode with the following steps. The goal is to confirm that some change that should trigger new cached data actually triggers a new cache key. For example, when the server bundle changes, does that trigger a new cache key for any server rendering?
- Run
Rails.cache.clearto clear the cache. - Run
rails dev:cacheto toggle caching in development mode.
You will see a message like:
Development mode is now being cached.
You might need to check your config/development.rb contains the following:
# Enable/disable caching. By default caching is disabled.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.cache_store = :memory_store
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=172800"
}
# For Rails >= 5.1 determines whether to log fragment cache reads and writes in verbose format as follows:
config.action_controller.enable_fragment_cache_logging = true
else
config.action_controller.perform_caching = false
config.cache_store = :null_store
end
-
Start your server in development mode. You should see cache entries in the console log. Fetch the page that uses the cache. Make a note of the cache key used for the cached component.
-
Suppose you want to confirm that updated JavaScript causes a cache key change. Make any change to the JavaScript that's server rendered or change the version of any package in the bundle.
-
Check the cache entry again. You should have noticed that it changed.
To avoid seeing the cache calls to the prerender_caching, you can temporarily set:
config.prerender_caching = false