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 to cache your own computed values with appCacheFetch() and friends
  • 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);
}
}

A form rendered with startFormTag() or buttonTo() carries the current session’s authenticity token in a hidden field. Never cache that markup where other visitors receive it. Every visitor would get the copy holding the token of the session that warmed the cache. That leaks the token, which someone could then use to forge requests against that session. It also makes everyone else’s submit fail with Wheels.InvalidAuthenticityToken (a 403).

So in any cached markup that more than one visitor sees:

  1. Leave the token out of the form with authenticityToken=false. This applies to every form in shared cached content, Turbo or not.
  2. Keep csrfMetaTags() out of the cached content. It belongs in the uncached layout, where it is rendered for each visitor.
  3. Supply the token at request time, from that meta tag.

From Wheels 4.2 the request passes if either the form field or the X-CSRF-Token header carries a valid token, and the header counts on any non-GET request, not only with X-Requested-With. Ways to send it:

  • Turbo sends the csrf-token meta tag’s value in X-CSRF-Token on every non-GET submit, so a Turbo-submitted form needs nothing more than step 1.
  • Your own fetch() does not add the header by itself. Set it explicitly:
fetch(url, {
method: "POST",
headers: { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content },
body: new FormData(form)
});
  • Plain HTML form posts can’t set headers. Fill the hidden field in on submit from the meta tag:
app/views/posts/_post.cfm (rendered with cache=...)
<cfoutput>
#buttonTo(text="Delete", route="post", key=arguments.id, method="delete", authenticityToken=false, class="js-fresh-token")#
</cfoutput>
layout (uncached): add once, after csrfMetaTags()
<script>
document.addEventListener("submit", function (event) {
var form = event.target;
if (!form.classList.contains("js-fresh-token")) return;
var meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) return;
var field = form.querySelector('input[name="authenticityToken"]');
if (!field) {
field = document.createElement("input");
field.type = "hidden";
field.name = "authenticityToken";
form.appendChild(field);
}
field.value = meta.content;
});
</script>

startFormTag() takes the same authenticityToken=false argument. Caching per user (see Per-user keys) avoids the problem too, but it gives up the shared fragment.

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.

Separate from findAll(cache=N) above, Wheels keeps a second, much shorter-lived query cache that is on by default: cacheQueriesDuringRequest. Within a single request, the first time a finder runs a given SQL statement its result is remembered, and an identical finder later in the same request returns that result instead of hitting the database again. It is what keeps a view that calls the same finder in a loop — or across several partials — from running the query many times. The cache lives in request scope, so it never outlives the request and is never shared between requests, visitors, or nodes.

You rarely interact with it directly, because Wheels keeps it correct for you:

  • Any ORM write clears the whole per-request cache. create(), update(), delete(), save(), and the bulk forms (updateAll / deleteAll / updateByKey / deleteByKey / insertAll / upsertAll) drop every cached finder result for the rest of the request — not just the written model’s. This matters for include=: a query that joins another model is cached under the base model, so a write to the included model has to clear it too, or the base finder would keep returning the joined model’s pre-write columns.
  • A transaction rollback clears it too. A finder that ran inside a transaction can cache rows the transaction then rolled back; clearing on rollback keeps those phantom rows from being served later in the request.
  • reload=true bypasses it for one call: model("Post").findByKey(key=id, reload=true) always goes to the database.
  • After a raw write you clear it yourself. A queryExecute("UPDATE …") is invisible to the ORM, so cached finders can go stale. Call forgetCachedQueries(all=true) after a raw write when the tables you changed are joined by other models’ include= queries; the single-model form forgetCachedQueries("Post") is enough for a raw single-table write that no other cached query joins. The same applies to a raw transaction {} block that rolls back — the automatic clear only fires for ORM transactions (model.transaction() / a write’s transaction= argument), so after rolling back a raw block call forgetCachedQueries(all=true) to drop any rows it cached.

Turn the whole thing off with set(cacheQueriesDuringRequest=false) in config/settings.cfm if you want every finder to hit the database.

When the slow part is a value you compute yourself (a total, a call to another service, a struct built from several queries), cache it with appCacheFetch(). It returns the cached value, or calls your function, caches what it returns and returns that:

app/controllers/Dashboard.cfc
component extends="Controller" {
function index() {
// Computed at most once every 10 minutes.
stats = appCacheFetch("dashboard-stats", function() {
return {
orders = model("Order").count(),
revenue = model("Order").sum("total")
};
}, 10);
}
}
FunctionDoes
appCacheFetch(key, callback, time)Returns the cached value. On a miss, calls callback, caches its result and returns it
appCacheRead(key, defaultValue)The cached value, or defaultValue (default "") when there’s none
appCacheWrite(key, value, time)Caches value; returns false when the cache is full and it wasn’t stored
appCacheExists(key)true while an unexpired entry exists
appCacheDelete(key)Removes the entry; true if there was one
appCacheClear()Removes every entry these functions wrote, and nothing else

They’re available in controllers, models, views, jobs and specs. How they behave:

  • Works in every environment. Unlike caches(), cache= on a partial and findAll(cache=N), these aren’t switched off in development, because your code asked for the cache explicitly.
  • time is in cacheDatePart units, minutes by default, like cache=N. Leave it out to use defaultCacheTime.
  • A cached false, 0 or "" is a hit. appCacheFetch() doesn’t call callback again for it, and appCacheExists() returns true.
  • Keys are case-sensitive strings, or a struct or array of values, such as ["user", userId, "orders"]. Struct key order doesn’t matter.
  • Keys are shared by every request. The same key reads the same entry for every user, session, tenant and host name. Nothing about the current request is added to it. For anything that differs per user, role or tenant, put that identity in the key, for example appCacheFetch("digest-" & userId, ...) or appCacheFetch(["orders", tenantId, userId], ...). Otherwise one user’s or tenant’s value is served to the next.
  • Values are copied in and out, so changing a struct you read doesn’t change the cached one.
  • A callback that returns nothing caches nothing, and appCacheFetch() returns "". A callback that throws caches nothing and the error reaches you.
  • No stampede protection. The callback runs outside the cache lock, so two requests that miss at the same moment can both compute the value, and the later write wins.
  • Its own space. Entries live in a data category that the framework never writes to, so appCacheClear() never empties the action, page, partial or query caches, and clearing those never removes your entries. It does share the maximumItemsToCache limit: when the cache is full, appCacheWrite() returns false, and appCacheFetch() still returns the value it computed but doesn’t cache it, so the next call computes it again. Like the rest of the cache, it lives in this server’s memory: an application reload or restart empties it, and each server behind a load balancer has its own.

Delete an entry when the data behind it changes, for example from a model callback (see Invalidate from the model):

appCacheDelete("dashboard-stats");

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, data for appCacheFetch() and friends, 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.

Separate from application.wheels.cache — the store everything above talks about — Wheels keeps a second, longer-lived cache that $clearCache() cannot reach: each model’s table and column metadata. The first time a model is used, Wheels runs cfdbinfo against its table and caches the column list, types, and defaults in application.wheels.models for the lifetime of the application. That’s what lets model("Post").new() know its properties without a database round-trip on every request.

The flip side: a live schema change goes stale instantly. Drop or rename a column with ALTER TABLE against a running app and the next request fails with a “column not found” error (or silently ignores the new column) — the model is still working from the metadata it read at startup. $clearCache(), clearQueryCacheOnReload, and clearTemplateCacheOnReload do not rebuild it; only a reload or a restart re-runs cfdbinfo.

To pick up a schema change without killing the process, in order of preference:

  1. Migrate, then reload. Apply the change through a migration and reload each node with ?reload=true&password=... — the reload re-reads every model’s schema. With more than one app server, reload them one at a time for a zero-downtime rollout.
  2. Rolling load-balancer restart. Drain and restart each node behind the load balancer in turn, so the fleet is never fully down.
  3. Off-hours engine restart. The blunt option: a full Lucee/CF restart clears everything. Fine when a maintenance window is acceptable.

Every request builds a controller instance and mixes the framework’s controller and view helpers into it. Outside development, Wheels records once per controller class which helpers to add and which super<name> aliases to write, then applies that record to each new instance instead of walking every helper again. The setting is cacheControllerIntegration: true in every environment except development, where controller methods change without a reload. A reload clears the records.

Turn it off with set(cacheControllerIntegration=false) if a controller defines a method conditionally while it’s being constructed (for example, a pseudo-constructor that assigns a function to variables only in some cases) and calls super<name>() for it. The record is per class, so such an instance gets the method but not its super<name> alias.

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 temporarily — cacheActions=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).