What this application caches, and why
A cache is a bet more than a speed trick. It bets that some work is expensive to produce, cheap to store, requested repeatedly, and acceptable when slightly out of date. All four have to hold. When one of them doesn't, caching makes the system worse in a way that's hard to see.
What caching is generally for
The pattern always has the same shape. Something takes time to compute: a database aggregation, a rendered template, a response from another service. Someone asks for that result again, the same user or a different one, before the underlying facts change. Storing the answer turns repeated computation into a single lookup.
The fourth condition is the one people skip. A cache always serves an answer computed in the past. Some data must be correct to the instant: a balance, a permission check, a stock count. For that data the only correct cache lifetime is zero, and reaching for a cache means reaching for a bug.
flowchart TB
Q["is this work expensive to produce?"] --> Q2["is it requested repeatedly?"]
Q2 --> Q3["is a slightly old answer acceptable?"]
Q3 -->|yes to all three| C["cache it"]
Q3 -->|no| N["do not cache it. a stale answer here is a bug"]
C --> K["then the only hard part is knowing when it is no longer valid"]
The backend, in one line
# config/environments/development.rb, production.rb, test.rb
config.cache_store = :solid_cache_store
The setting is the same in all three environments, and that's a decision worth noticing. This application's job queue uses :async in development and :solid_queue elsewhere, but caching doesn't get that split. What you see locally is what production does.
solid_cache is a first-party Rails 8 gem. Each member of the "Solid" family replaces an external service (a cache, a job queue, a WebSocket broker) with the database this application already runs. The rest of this course covers what that costs and what it looks like.
What this application caches
There are two patterns in seven places, and the boundary between them is worth seeing.
Six fragment caches store rendered HTML. The largest is the roadmap. Its key comes from a helper method rather than an inline block, so a whole group of cards resolves with one read_multi call instead of one cache read per card:
# app/helpers/catalog/course_helper.rb
def card_cache_key(course, progress)
prog = progress[course.id]
[ course, I18n.locale, current_user.present?, prog&.percentage, prog&.enrolled ]
end
That partial renders once per active course, across intern, junior, mid and senior. In this development database that's 361 cards for a signed-in visitor; anonymous visitors see a shorter list per level. Each card holds level styling, a cover image variant, a title, a lesson count and a progress bar. Without the cache, every visit to the busiest page in the product would render hundreds of templates.
The other five cache the dashboard's ongoing-course cards, suggested courses, completed-course summaries, each lesson row on a course page, and the "Supported by" partner line in the footer of every page.
One value cache stores a Ruby object rather than markup:
Rails.cache.fetch("admin/stats/#{resolved}", expires_in: 5.minutes) do
Admin::StatsQuery builds eight chart datasets and a set of counters. On this machine's development data it took about 16 ms cold and about 1 ms from the cache, roughly fifteen times faster.
Why the two are keyed so differently
This is the most transferable idea in the course. The fragment caches carry no expiry at all, while the stats cache expires in five minutes. That's deliberate.
A fragment key names the record. For [course, I18n.locale, prog&.percentage], Rails stores the entry under the course's cache_key and records the course's updated_at as the entry's version. When the course changes, the stored version no longer matches, so the next read is a miss and the new render replaces the entry. It needs no expiry because it can't serve a stale course.
The stats cache can't work that way, because its result comes from thousands of rows across many tables. There's no single updated_at to version on, so the only available answer is a time limit: accept data up to five minutes old.
The rule: if you can name what the answer depends on, version on it and never expire. If you can't, choose a staleness you can defend out loud.
What this application doesn't cache
Everything else, and that's the correct default. There are no cached permission checks, no cached user records and no cached quiz results. A cache is a liability on anything that must be right. In this codebase only rendered presentation and an admin dashboard are cached, never a fact a decision depends on.
The same store does hold one thing that isn't a cache: Rails' rate_limit, used by a few controllers here, keeps its request counters in Rails.cache. That's state a decision depends on, and it lives in Solid Cache because rate_limit uses the configured cache store by default.
Key takeaways
- Caching bets that work is expensive, cheap to store, repeated, and acceptable when slightly stale: all four must hold.
- This application caches in seven places: six HTML fragment sites and one Ruby value in
Admin::StatsQuery. - The roadmap renders one course card per active course in a single request, which is why fragment caching earns its place there.
- Fragments never expire because their version is the record's
updated_at, so a changed record misses and re-renders; the stats cache expires in five minutes because it has no single record to version on. - Nothing a decision depends on is cached, though
rate_limit's counters share the same store.
Keep going with lesson 2
Every element in this app's multi-part cache keys
Explains why locale, sign-in state and per-student progress values each appear in this app's array cache keys.
A free account also gives you the quizzes, your progress and notes, and the discussion.