Digging Deeper
Caching
This page shows you how to keep expensive work out of the hot path. You’ll cache a whole controller action, cache a slow partial inside a view, cache a query result, key caches per-user, and invalidate everything from a model callback when the underlying data changes.
You’ll learn:
- How to cache a controller action with
caches()inconfig() - How to cache a partial or query for a fixed number of minutes
- How Wheels stores cache entries and when to reach for an external store
- How to invalidate cache keys from model callbacks so stale reads never ship
- How to key caches per user with
appendToKey— and when not to cache at all
Cache a whole action
Section titled “Cache a whole action”The simplest win: tell Wheels to serve the full rendered response for an action from memory. Declare it in the controller’s config():
component extends="Controller" { function config() { caches(action="index", time=10); }
function index() { posts = model("Post").published().findAll(order="publishedAt DESC"); }}time is minutes. The first request runs index(), renders the view, and stores the HTML keyed by controller + action + params. Every subsequent request inside the next ten minutes returns the cached HTML without re-running the action or hitting the database.
Rules:
caches()accepts eitheraction="name"oractions="one,two,three"— same argument, aliased.timedefaults to 60 minutes. Pass a number to override. (The separatedefaultCacheTimesetting governscache=trueon finders andrenderView()/renderPartial()—caches()carries its own hardcoded 60, soset(defaultCacheTime=15)won’t change it.)- Caches are skipped automatically when the request has a flash message or a non-empty
formscope. Wheels assumes a flash or form submission means the user just did something — serving them yesterday’s HTML would be wrong. - Omit
actionentirely (caches()) and every action in the controller is cacheable.
component extends="Controller" { function config() { // Cache the listing for 15 minutes and the detail page for an hour. caches(action="index", time=15); caches(action="show", time=60); }
function index() { products = model("Product").findAll(); } function show() { product = model("Product").findByKey(params.key); }}Static pages skip filters
Section titled “Static pages skip filters”Pass static=true and Wheels uses Lucee’s cfcache server-side cache, which short-circuits the request even earlier — before filters run at all. Use this for marketing pages, pricing tables, anything truly public:
component extends="Controller" { function config() { caches(action="pricing", time=120, static=true); }
function pricing() { plans = model("Plan").findAll(order="monthlyPrice"); }}Don’t use static=true on any action that depends on the logged-in user, a flash message, or the CSRF token.
Cache a fragment in a view
Section titled “Cache a fragment in a view”When only part of a page is slow — a sidebar, a footer, a tag cloud — don’t cache the whole action. Cache the partial instead. includePartial() takes a cache argument:
<cfparam name="posts" default=""><cfoutput> <h1>Recent posts</h1>
<div class="layout"> <main> <cfloop query="posts"> <article><h2>#posts.title#</h2></article> </cfloop> </main>
<aside> #includePartial(partial="popularTags", cache=30)# </aside> </div></cfoutput>The partial at app/views/posts/_popularTags.cfm renders once per 30 minutes. The outer posts listing still re-renders every request — only the fragment is cached.
renderView() and renderPartial() both accept the same cache=N argument if you’re rendering explicitly from a controller:
component extends="Controller" { function summary() { stats = model("Report").findAll(); renderView(cache=5); }}Cache a query
Section titled “Cache a query”Most slow actions aren’t slow because of rendering — they’re slow because of one expensive query. Cache the query directly and leave the view alone:
component extends="Controller" { function dashboard() { // Cache the aggregation for 5 minutes — the view can re-render freely. revenueByDay = model("Order").findAll( select="DATE(createdAt) AS day, SUM(total) AS revenue", group="DATE(createdAt)", order="day DESC", cache=5 ); }}findAll(cache=N) and findByKey(cache=N) hash the query arguments into the cache key, so two callers with identical arguments share one result. Pass cache=true to use the default cache time.
Query caching is ideal when:
- The query is expensive (aggregations, joins across large tables).
- Per-request result variation is acceptable (a 5-minute-old revenue number is fine on a dashboard).
- The view output depends on other per-request data (current user, feature flags) so whole-action caching won’t work.
Where cache entries live
Section titled “Where cache entries live”Wheels keeps cache entries in application.wheels.cache — an in-memory struct on the CFML application scope, split into categories (action, page, partial, sql, image, main, plus a legacy query category that nothing writes to). Reads are struct lookups; writes are struct writes. No external service required.
One exception: findAll(cache=N) / findByKey(cache=N) results do not live in this struct. Cached finder results are stored in the CFML engine’s native query cache (via cachedWithin); only the generated SQL shell lands in the sql category.
| Property | Default | Meaning |
|---|---|---|
defaultCacheTime | 60 | Default duration in minutes when cache=true is passed without a number. |
cacheDatePart | "n" | n = minutes. Change to h for hours, s for seconds. |
maximumItemsToCache | 5000 | Total entries across all categories. |
cacheCullPercentage | 10 | When full, purge this percent of expired entries before accepting new ones. |
cacheCullInterval | 5 | Don’t cull more often than once every 5 minutes. |
Tune any of these in config/settings.cfm via set(defaultCacheTime=15), etc.
Invalidate from the model
Section titled “Invalidate from the model”Caches go stale the moment the underlying data changes. The clean pattern: drop invalidation into a model callback so every write path invalidates for free.
component extends="Model" { function config() { afterSave("clearPostCache"); afterDelete("clearPostCache"); }
private function clearPostCache() { // Remove named entries so the next read repopulates from the DB. $removeFromCache(key="popular-posts"); $removeFromCache(key="post-count"); }}$removeFromCache(key, category) deletes a single entry. $clearCache(category) drops a whole category. $clearCache() with no args wipes everything — handy in a dev-only “purge” admin action.
For caches keyed by query arguments (the automatic findAll(cache=N) flavor), invalidation is harder — the key is a hash of all the finder arguments. Three options, in order of preference:
-
Let TTL handle it. If a 5-minute stale window is acceptable, do nothing; the entry expires on its own.
-
Cache under a named key you control by building the key yourself with
$addToCache()/$getFromCache():app/controllers/Posts.cfc component extends="Controller" {function index() {posts = application.wo.$getFromCache(key="posts-listing");if (!IsQuery(posts)) {posts = model("Post").published().findAll();application.wo.$addToCache(key="posts-listing", value=posts, time=10);}}}Now the model callback can call
$removeFromCache(key="posts-listing")surgically. -
Bust the engine’s query cache with a reload.
$clearCache(category="query")does not invalidate finder caches — as noted above,findAll(cache=N)results live in the CFML engine’s native query cache, not inapplication.wheels.cache. The blunt instruments that actually work are?reload=true&password=...(which rotates the cache-key comment Wheels embeds in every cached query’s SQL, invalidating all engine-cached results) or an application restart.
Per-user keys
Section titled “Per-user keys”A cached action is shared across every request for the same URL — which is a problem the moment the rendered output depends on the logged-in user. Use appendToKey to mix more identifiers into the cache key:
component extends="Controller" { function config() { // Each user gets their own cached dashboard for 5 minutes. caches(action="index", time=5, appendToKey="session.userId"); }
function index() { user = model("User").findByKey(session.userId); recentActivity = user.activities(order="createdAt DESC"); }}appendToKey takes a comma-separated list of dot-notation paths. Wheels evaluates each at request time and appends the value to the cache key. Supported scopes: session, request, application, arguments, variables. Pass more than one (appendToKey="session.userId,session.tenantId") to key per user and per tenant.
For multi-tenant apps, see Multi-tenancy for the tenant-scoped key pattern.
When not to cache
Section titled “When not to cache”Caching always trades correctness for speed. Don’t reach for it when:
- The data is per-user. Either include the user in the key (
appendToKey="session.userId") or skip the cache. An unkeyed action cache serves User A’s dashboard to User B. - The write already just happened. Caching a
create/update/destroyresponse makes no sense — those aren’t idempotent and Wheels correctly skips the cache whenformis non-empty. Don’t work around it. - The query is already fast. Sub-millisecond indexed lookups don’t need caching. You’re adding a second source of truth (the cache) and a new invalidation bug for no speedup.
- You can’t invalidate confidently. If you can’t list the write paths that would stale the cache, the cache will go stale. Shorten the TTL or skip caching.
Page caching at the edge
Section titled “Page caching at the edge”Wheels’s in-memory cache lives inside your CFML process. For truly static pages — documentation, marketing, blog posts — push caching further out: a reverse proxy (Nginx, HAProxy) or a CDN (Cloudflare, CloudFront, Fastly) serves the HTML without touching your app at all.
Configure edge caching at the deploy layer, not in Wheels. The Deployment guide covers setting Cache-Control headers from your controller, letting Kamal’s proxy or your CDN honor them, and purging the edge cache when a model saves.
Debugging cached responses
Section titled “Debugging cached responses”Three ways to confirm what you’re seeing:
-
Disable caching temporarily —
cacheActions=falseis already the development default (the framework sets it; no config file needed). Flipping it on withset(cacheActions=true)inconfig/development/settings.cfmreproduces production behavior when you’re investigating a stale-read bug. -
Pass
time=0to force re-rendering while you leave thecaches()declaration in place:app/controllers/Posts.cfc (debugging) component extends="Controller" {function config() {// Temporarily disable — put the real time back before committing.caches(action="index", time=0);}} -
Clear caches on reload.
?reload=true&password=...flushes query and template caches automatically (clearQueryCacheOnReload,clearTemplateCacheOnReload). Combine with a custom admin action that callsapplication.wo.$clearCache()when you need to purge the full cache without a restart. Note: whenreloadPasswordis empty, URL-based reload is disabled entirely — set a non-emptyreloadPasswordanywhere you want?reload=trueto work (see #3062).