Manual contentsDigging DeeperBrowse 113 chapters
Manual 16 min read

RenderCache

RenderCache stores a proven-safe copy of a GET or HEAD route's response and serves the next matching request from it without running your handler at all. You opt routes and groups in explicitly; everything else keeps working exactly as it does today. A route you never opt in is untouched. A route you do opt in still renders and serves correctly even when nothing about that particular request turns out to be safe to cache - it just never gets stored, and you can find out why.

This chapter covers enabling the cache, opting routes and groups in, declaring variance, reading the response headers it adds, the reasons a render is declined, operational control, and how it differs from suprnova::Cache.

The chapters

This is the first of five. Read them in order the first time; after that, each answers one question on its own.

Chapter Answers
RenderCache (this one) How do I turn it on and opt a route in?
Representations What is actually stored, and under what key?
Generations When does a stored copy stop being current?
Deployment How do several nodes share one cache?
Operations How do I inspect it, test it, measure it, and switch it off?

Enabling the cache

Two environment variables matter to start:

  • RENDER_CACHE_ENABLED - true unless set to false or 0. With it disabled, every request bypasses RenderCache entirely; nothing is looked up and nothing is stored.
  • RENDER_CACHE_L1_DIR - unset by default, which means no on-disk tier. Set it to a directory the process can create and write to, and stored representations survive a process restart in a file-backed second tier.

A handful of other variables tune the defaults: RENDER_CACHE_L0_ENTRIES (4,096) and RENDER_CACHE_L0_BYTES (128 MiB) bound the in-process tier; RENDER_CACHE_L1_BYTES (1 GiB) bounds the file tier; RENDER_CACHE_FAILURE (open by default, or closed) decides whether a store or database problem serves the route uncached or refuses the request; APP_BUILD_ID namespaces every cached entry to the build that produced it. Set it explicitly to something that changes every deploy: its default is a compiled-in crate version, which does not. See RenderCache Deployment.

RENDER_CACHE_PROFILE (embedded by default, or database or redis) chooses whether the second tier and the rebuild coordinator are in this process or shared with every other node. A shared profile also needs a migration your application lists. Both are the Deployment chapter's subject, together with the full variable table.

Opting a route or a group in

Nothing is cached until you say so. Router::try_render_cache opts one already-registered route pattern in; Router::try_render_cache_group opts every route under a path prefix in. Both take a policy built with RenderCachePolicy::builder:

use suprnova::{FrameworkError, Router};
use suprnova::render_cache::{
    FreshnessPolicy, RenderCachePolicy, RepresentationClass, SharedCachePolicy,
};

fn add_render_cache(router: Router) -> Result<Router, FrameworkError> {
    router.try_render_cache_group(
        "/blog",
        RenderCachePolicy::builder(RepresentationClass::PublicShared)
            .freshness(FreshnessPolicy::new(300_000, 60_000, 300_000)?)
            .shared(SharedCachePolicy::SMaxAge { seconds: 300 })
            .build()?,
    )
}

FreshnessPolicy::new(fresh_ms, stale_servable_ms, stale_on_error_ms) sets how long a representation is fresh, and then two windows measured from that fresh edge: how far past it the stored copy may still be served while a background rebuild runs, and how far past it the stored copy may be served if a foreground rebuild fails outright. The two windows are not stacked; see RenderCache Representations.

RepresentationClass runs from widest to narrowest sharing: PublicShared (one representation for everyone who matches the declared variance), PublicShellStitched (a Live document whose shared shell is stored once and whose islands are re-mounted for whoever is asking; see Representations), PrivateCached (one representation per signed-in visitor or tenant), and Uncacheable.

A route pattern must already be registered before you opt it in, and you must finish opting routes and groups in before calling RenderCache::install (below) - the install step reads whatever has been registered by that point.

A route-level policy can also be a narrowing patch of its enclosing group, using PolicyPatch instead of a full RenderCachePolicy: it inherits everything the group declared and may only make it narrower (a shorter freshness window, a stricter class), never wider. Pulling one route out of a cached group entirely is a PolicyPatch that sets the class to Uncacheable.

Finish wiring RenderCache in with one line, after every middleware registration that establishes request-scoped locale, session, or identity (RenderCache reads them to build its lookup key, so it needs to run after whatever sets them up):

use suprnova::RenderCache;
use suprnova::render_cache::RenderCacheConfig;

Application::new()
    // ...
    .try_routes_async(|| async {
        let router = add_render_cache(routes::register())?;
        RenderCache::install(router, RenderCacheConfig::from_env()).await
    });

Declaring variance

By default a cached representation varies only by route pattern, path parameters, and the application build. Anything else your handler's output actually depends on needs to be declared, with two mechanisms:

  • Query parameters. .query(QueryPolicy::declared(["page", "sort"])) names the query parameters that distinguish representations; any other query parameter present on a request bypasses the cache for that request rather than being silently ignored.

  • Variance dimensions, added one at a time with .vary(dimension):

    • VarianceDimension::Locale partitions by the negotiated locale.
    • VarianceDimension::Host partitions by the request's host, where your deployment makes more than one host meaningful.
    • VarianceDimension::Tenant partitions by the current tenant as opaque key material; a route whose handler ever reads the tenant must declare it.
    • VarianceDimension::Principal partitions by the signed-in visitor as opaque key material, bound to a permission version (see "Epoch, permissions, and inspection" below); a PrivateCached route must declare Principal or Tenant (or both) or it fails to build at all.
  • Media and Encoding, declared together with their own closed set: .vary_media(NegotiatedPolicy::declared(["text/html", "application/json"], "text/html")?) and .vary_encoding(NegotiatedPolicy::declared(["identity", "gzip"], "identity")?). The bare .vary(VarianceDimension::Media) (or ::Encoding) is rejected at build/apply: unlike every other dimension, these two negotiate against a set only the route can name, so there is nothing to key by without it.

    Negotiation reads the request's Accept (for Media) or Accept-Encoding (for Encoding) header, matches it against the declared set, and adds the matching request header to Vary. It is q-weighted: the declared member with the highest quality wins, and equal quality keeps the header's own left-to-right order, so the candidate listed first wins a tie. A wildcard (*/*, type/*, a bare *) is compared as a literal token, not expanded against the set, so it practically never matches a real declared value. An absent header, a value naming nothing in the declared set, or a header this cannot make sense of - a q=0, an out-of-range or unparsable quality, garbage syntax - resolves to the declared default rather than creating a variant or failing the request. Two different negotiated values are two different keys; the same negotiated value, however it was spelled or weighted on the wire, is always the one stored representation for it.

VarianceDimension::FeatureVersion, VarianceDimension::ConfigVersion, and a custom VarianceDimension::Application(name) exist on the type but have no resolver in this release: a route that declares one bypasses the cache on every request, silently, rather than failing to build. Do not declare them yet.

Reading the response headers

A served hit carries ETag (a strong validator your client can send back as If-None-Match for a 304), Cache-Control, Vary, and Age (whole seconds since the representation was published, and the quickest local sign that a response came out of the store rather than out of your handler). A response served past its fresh interval additionally carries Warning: 110 - "Response is Stale". Each of the five is defined, with the values the dogfood routes are asserted to send, in RenderCache Representations.

Why a render is never stored

Being opted in is not a guarantee. Two independent checks run after every render, and either can decline storage without failing the request - the response you get back is identical either way, it just never becomes a cache entry:

Eligibility declines outright for a response that is not a plain 200 to a GET or HEAD, that streams its body, that sets a cookie, or that carries a hop-by-hop or tracing header. These are almost always accidental (a redirect, an error page, a response that happens to touch Set-Cookie) rather than something you need to design around.

Classification declines based on what your handler actually did while it ran, in terms you will recognize:

  • You read a session value. Any read of the current session (through session(), session_mut, or a session cookie) forces the render to Uncacheable, permanently, no matter what variance the route declares. The one thing this does not cover is the signed-in visitor's own identity. Auth::id() reads it out of the session when nothing earlier in the request resolved it, and that read is classified as an identity read, not a session read - so an ordinary cookie-backed login is exactly what a PrivateCached route declaring Principal variance is for, and reaching for the visitor's id does not quietly make the page uncacheable. Every other value in the session still does. Two consequences worth knowing: an anonymous request to such a route caches under the Anonymous key, because the render resolved no identity, observed no principal material, and the key says so - a signed-in visitor derives a Private key that never reaches that entry; and a named guard's own identifier is principal material in exactly the same way as the default guard's.
  • You read an identity, on a route that does not declare Principal. Reading the signed-in user narrows the class to PrivateCached; if the route's declared variance does not include Principal, there is no way to key the entry per visitor, so it is declined rather than shared.
  • You translated (or your view engine did) without declaring Locale. Any read of the negotiated locale needs a declared Locale dimension, or the render is declined. Every Inertia page's document shell reads the locale to set <html lang>, whether or not the page's own data has anything to do with language - so an Inertia route needs Locale declared to ever cache at all, even one with no translated content of its own.
  • You checked authorization. A decision is judged by what its own evaluation read. A gate whose body reads only the tenant - through suprnova::live::current_tenant(), say - classifies under Tenant alone and caches on a route keyed by Tenant. A gate that reads a per-user fact, or that reads nothing RenderCache can see, still needs Principal declared: a body that decided from its user argument through no instrumented accessor is indistinguishable from one that decided from a constant, and the safe reading of that is the conservative one.
  • A model behind the page carries a global scope that reads per-request state. Declare what the scope depends on. A GlobalScope returning ScopeDependency::Constant records nothing and costs no cache hits. The default, ScopeDependency::PerRequest, requires the scope's apply to read that state through an instrumented accessor - suprnova::live::current_tenant(), Auth::id(), Lang::locale(). A per-request scope whose evaluation reads none of them narrows the render to Uncacheable and names itself in the decline, so an invisible tenant filter costs you the cache rather than costing your visitors each other's rows.
  • You read a secret configuration value, or an undeclared request context. Both force Uncacheable. A response's dependence on an ordinary request header, or on Config::get, is invisible to RenderCache entirely - it cannot decline what it cannot see, so declaring the matching variance is on you.
  • You ran raw SQL through DB::select, DB::select_one, DB::scalar, or DB::select_on. The framework cannot name the tables a raw statement read, so the render is never stored; it is still served. Reads through DB::table(..) know their table and are cached normally, and so is Auth::user(), which resolves through that path. The framework's own RBAC role and permission checks name the five tables they read - roles, permissions, role_permissions, model_roles, and model_permissions - so a cached route that evaluates one is observed precisely and cached normally.
  • The write was made by a queue worker, a scheduled task, or a console command. Nothing special is needed any more. Every process whose configuration enables RenderCache and whose database holds the RenderCache migration advances generations, so such a write invalidates exactly what the same write invalidates in the server, and RenderCache::bump_permission_version() works from any of them. A process with RENDER_CACHE_ENABLED=false, or one whose database does not hold the migration, advances nothing and issues no RenderCache SQL at all.

On PostgreSQL the render runs in a REPEATABLE READ transaction so that what it read and the generations it recorded agree; a cached route's handler that updates a row another transaction changed after the render began sees a serialization failure. Design cached routes as read paths. A handler that does write inside the render transaction still advances generations, but it competes with concurrent writers for the same rows and can see the serialization failure above.

A write made outside any transaction (model.save() on its own) commits first and advances its generations in an immediately following transaction, so the moment between the two is "new data, old generation": one extra rebuild, never stale content.

None of this needs special tooling to see happen in practice: the hidden render-cache:inspect command (below) shows whether a route's entry exists at all, or you can just try two requests in a row and check whether the second one carries an Age header.

A route that caches

A public listing page with no per-visitor content:

use suprnova::{handler, HttpResponse, Response};

#[handler]
pub async fn index() -> Response {
    let posts = Post::query().order_by_desc("published_at").get().await?;
    Ok(HttpResponse::html(render_post_list(&posts)))
}

registered and opted in:

use suprnova::{get, routes};
use suprnova::render_cache::{FreshnessPolicy, RenderCachePolicy, RepresentationClass, SharedCachePolicy};

routes! {
    get!("/blog", controllers::blog::index),
}

router.try_render_cache(
    "/blog",
    RenderCachePolicy::builder(RepresentationClass::PublicShared)
        .freshness(FreshnessPolicy::new(300_000, 60_000, 300_000)?)
        .shared(SharedCachePolicy::SMaxAge { seconds: 300 })
        .build()?,
)?;

index never touches the session, the signed-in visitor, or the locale, so the first request renders and publishes; every request for the next five minutes is served from that stored copy with an Age header, a 304 for a client that already has it, and Cache-Control: public, max-age=300, s-maxage=300 for any CDN in front of it.

A route that is declined

The same shape of page, but the handler reads the session to show a flash message:

use suprnova::session::session;
use suprnova::{handler, HttpResponse, Response};

#[handler]
pub async fn index() -> Response {
    let posts = Post::query().order_by_desc("published_at").get().await?;
    let flash = session().and_then(|s| s.get::<String>("status"));
    Ok(HttpResponse::html(render_post_list_with_flash(&posts, flash.as_deref())))
}

opted in exactly the same way as above. Every request still renders and serves the correct page - flash message included - but nothing is ever stored: the session read narrows the class to Uncacheable before RenderCache even reaches the eligibility check, so a second request for the same URL renders again from scratch rather than coming back with an Age header. The fix, if this page is meant to cache, is to stop reading the session in the cached path (render the flash from a query parameter or a separate small response instead) - there is no variance declaration that makes a session read cacheable, because a session read means the response depends on something no key could safely partition by.

Epoch, permissions, and inspection

  • RenderCache::bump_permission_version().await? - call this whenever an application action changes what a signed-in user is allowed to do (a role change, a permission grant or revocation). It advances a persisted generation that every principal-keyed render observes. The generation survives a restart, and the bump joins the transaction the role change runs in when there is one. Without the bump, a user whose permissions just changed keeps matching whatever was cached under their prior permission set.
  • RenderCache::advance_epoch(), or the hidden render-cache:epoch-advance command - an emergency invalidation. The epoch is baked into the lookup key itself, so advancing it puts stored entries out of reach with nothing to enumerate and nothing to delete. On the process that runs it the effect is immediate: it drops that process's epoch lease and clears its in-process tier the same instant. Another node catches up at its next authority read, and its file-backed tier keeps its old files until a sweep reclaims them - the automatic one every 256th publication, or an explicit RenderCache::sweep() - which is disk hygiene rather than a correctness concern. Reach for this when something is wrong with cached content and you cannot wait for individual entries to expire; on more than one node, see RenderCache Operations.
  • The hidden render-cache:inspect <key> command reports one stored entry's metadata (never its body) by the key text your application logs or telemetry can surface, alongside the current epoch, so you can tell whether what you are looking at is still live authority or has already aged out from underneath. It looks the key up in the running process's in-process tier only, never in the shared one, so on a database or redis profile it reports no entry for a key this node has not served itself.

RenderCache versus suprnova::Cache

suprnova::Cache is a key-value store you call explicitly: you choose the key, you choose what to store, you choose when to invalidate it (Cache::put, Cache::get, Cache::remember, Cache::forget). It works for any data your code decides is worth caching, on any backend you configure (memory or Redis).

RenderCache is not a general-purpose store, and you never call it from your handler. It caches whole HTTP responses, the key is derived automatically from the route and its declared variance, and invalidation is generation-based: an ordinary database write through the ORM or query builder advances the generations the render depended on, and the entry is recomputed the next time it is asked for rather than deleted by hand; a render that read raw SQL is never stored in the first place, so there is nothing to recompute. Reach for suprnova::Cache when you have a specific value you want to compute once and reuse; reach for RenderCache when you have a whole route whose response is expensive to render and safe to share.

Why Suprnova diverges

Laravel has no equivalent in the framework itself. Response caching is a package you add, it wraps the route in middleware that stores the rendered response under a key you compose, and everything after that is yours: which routes are safe to cache, what makes two visitors different, and when a stored page stops being true. The framework does not know a page was cached, so it cannot tell you when caching one was a mistake.

RenderCache is part of the framework for exactly that reason. It sees the render happen, so it can record what the handler read, compare that against what the route declared, and refuse to store a response whose safety it cannot account for - silently, without changing what the visitor is served. Opting a route in is a declaration the framework then holds you to, rather than a promise you make to yourself. The cost is that some routes you would like to cache are declined and you have to find out why; the benefit is that the ones that are stored were proven safe to store, once, by the process that rendered them.

Next