Skip to content

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() in config()
  • 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

The simplest win: tell Wheels to serve the full rendered response for an action from memory. Declare it in the controller’s config():

app/controllers/Posts.cfc
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 either action="name" or actions="one,two,three" — same argument, aliased.
  • time defaults to 60 minutes. Pass a number to override. (The separate defaultCacheTime setting governs cache=true on finders and renderView()/renderPartial()caches() carries its own hardcoded 60, so set(defaultCacheTime=15) won’t change it.)
  • Caches are skipped automatically when the request has a flash message or a non-empty form scope. Wheels assumes a flash or form submission means the user just did something — serving them yesterday’s HTML would be wrong.
  • Omit action entirely (caches()) and every action in the controller is cacheable.
app/controllers/Products.cfc
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); }
}

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:

app/controllers/Marketing.cfc
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.

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:

app/views/posts/index.cfm
<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:

app/controllers/Dashboards.cfc
component extends="Controller" {
function summary() {
stats = model("Report").findAll();
renderView(cache=5);
}
}

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:

app/controllers/Analytics.cfc
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.

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.

PropertyDefaultMeaning
defaultCacheTime60Default duration in minutes when cache=true is passed without a number.
cacheDatePart"n"n = minutes. Change to h for hours, s for seconds.
maximumItemsToCache5000Total entries across all categories.
cacheCullPercentage10When full, purge this percent of expired entries before accepting new ones.
cacheCullInterval5Don’t cull more often than once every 5 minutes.

Tune any of these in config/settings.cfm via set(defaultCacheTime=15), etc.

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.

app/models/Post.cfc
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:

  1. Let TTL handle it. If a 5-minute stale window is acceptable, do nothing; the entry expires on its own.

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

  3. 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 in application.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.

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:

app/controllers/Dashboards.cfc
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.

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/destroy response makes no sense — those aren’t idempotent and Wheels correctly skips the cache when form is 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.

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.

Three ways to confirm what you’re seeing:

  1. Disable caching temporarilycacheActions=false is already the development default (the framework sets it; no config file needed). Flipping it on with set(cacheActions=true) in config/development/settings.cfm reproduces production behavior when you’re investigating a stale-read bug.

  2. Pass time=0 to force re-rendering while you leave the caches() 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);
    }
    }
  3. Clear caches on reload. ?reload=true&password=... flushes query and template caches automatically (clearQueryCacheOnReload, clearTemplateCacheOnReload). Combine with a custom admin action that calls application.wo.$clearCache() when you need to purge the full cache without a restart. Note: when reloadPassword is empty, URL-based reload is disabled entirely — set a non-empty reloadPassword anywhere you want ?reload=true to work (see #3062).