This version is in beta. Some features may change before release.

Caching

In-memory, SQLite, and Redis cache backends plus view-level cache_page middleware for umbral.

Caching

umbral's cache plugin (umbral-cache) gives you one Cache handle over a swappable CacheBackend trait, three built-in backends (memory, SQLite, Redis), and a view-level cache_page middleware that caches full GET responses for a route subtree.

Backends

In-memory (development default)

Code
rust
let cache = Cache::memory();

HashMap with per-key expiry tracked in-process. Lost on restart. Best for local development and single-process deployments where warming the cache on restart is cheap.

SQLite

Code
rust
let pool = SqlitePoolOptions::new().connect("sqlite://cache.db?mode=rwc").await?;
let cache = Cache::sqlite(pool).await?;

Table-backed and durable across restarts. Expired rows are skipped on read; call SqliteBackend::sweep() from a background task to physically remove them.

Redis (production)

Requires the redis cargo feature:

Code
toml
umbral-cache = { version = "0.0.1", features = ["redis"] }
Code
rust
let cache = Cache::redis("redis://localhost:6379/0").await?;

URL form: redis://[user:pass@]host:port/[db]. Uses redis::aio::ConnectionManager which reconnects automatically on dropped connections. clear() calls FLUSHDB on the selected database, so use a dedicated database (e.g. /1) when sharing a Redis instance with other data.

Registering the ambient cache

cache_page and umbral_cache::ambient() both read the process-wide ambient handle. The idiomatic way to install it is to register CachePlugin::new(cache) on the builder - on_ready wires the carried cache as the ambient handle at boot:

Code
rust
App::builder()
.plugin(CachePlugin::new(Cache::memory())) // development
// or: CachePlugin::new(Cache::redis("redis://localhost:6379/0").await?) // production
.build()
.await?;

CachePlugin::init(cache) remains for manual or test wiring outside the plugin lifecycle (it panics if called more than once):

Code
rust
CachePlugin::init(Cache::memory());

Using the cache in handlers

Reach the process-wide handle from any handler with umbral_cache::ambient() (it returns Option<&'static Cache>, None until CachePlugin::init has run):

Code
rust
use std::time::Duration;
 
async fn home() -> impl IntoResponse {
let cache = umbral_cache::ambient().expect("cache initialised at boot");
if let Some(html) = cache.get::<String>("homepage:html").await {
return Html(html);
}
let rendered = render_homepage();
cache.set("homepage:html", &rendered, Some(Duration::from_secs(60))).await.ok();
Html(rendered)
}

set serialises with serde_json (returning Result<(), serde_json::Error>). get deserialises; a decode error is silently treated as a miss. (If you prefer dependency injection over the ambient handle, add your own Extension(cache) layer in App::builder() and take Extension<Cache> in the handler.)

View-level caching (cache_page)

cache_page(ttl) is a tower Layer that caches full GET/HEAD responses for a route subtree, keyed by method and URI.

Code
rust
use umbral_cache::cache_page;
use std::time::Duration;
 
let public = Router::new()
.route("/", get(home))
.route("/about", get(about))
.layer(cache_page(Duration::from_secs(60)));

Cache key: cache:page:<METHOD>:<URI> including query string, so /blog?page=1 and /blog?page=2 are stored separately.

What gets cached: only GET and HEAD responses with status 200. The following bypass caching:

  • Any method other than GET or HEAD
  • Status other than 200
  • Response carries Cache-Control: no-store
  • Response carries a Set-Cookie header (personalised responses)

If the ambient cache was not initialised, misses and stores are silently skipped - the handler fires normally.

Opt-in compression and HTTP cache headers

These are router-wide, default-off knobs on CachePlugin. They are orthogonal to cache_page (which caches server-side response bodies) - these emit HTTP headers that tell browsers and downstream proxies how to handle responses.

Response compression

Code
rust
CachePlugin::new(Cache::memory())
.with_compression()

Applies tower_http::CompressionLayer to the router. The layer negotiates with the client's Accept-Encoding header and compresses responses with gzip, brotli, deflate, or zstd as available. No compression is applied when the client sends no Accept-Encoding, or when tower-http's default predicate suppresses it (responses below 32 bytes, gRPC responses, and image content-types are skipped by default).

Cache-Control and Vary headers

Code
rust
CachePlugin::new(Cache::memory())
.cache_control("public, max-age=3600")
.vary("Accept-Encoding")

cache_control(value) emits a Cache-Control response header with the given directive string on every response. vary(value) emits a Vary header. Both use SetResponseHeaderLayer::overriding, so the plugin's value takes precedence over anything the handler set.

Pair Vary: Accept-Encoding with .with_compression() so shared caches (CDNs, reverse proxies) store separate variants per encoding.

Composing all three

Code
rust
App::builder()
.plugin(
CachePlugin::new(Cache::memory())
.with_compression()
.cache_control("public, max-age=3600")
.vary("Accept-Encoding"),
)
.build()
.await?;

The three builder methods can be mixed freely with the existing cache_page layer - they operate at different levels of the stack.

Scaling to production

Development: Cache::memory() - zero config. Staging or small prod: Cache::sqlite(pool) - durable, no extra service. Multi-instance production: Cache::redis(url) - shared across all app processes, server-side TTL, millisecond latency.

Deferred

  • get_or_set helper.
  • incr/decr atomic ops.
  • Memcached backend.
  • Distributed cache invalidation (tag-based).
  • ETag/304 conditional caching in cache_page.
  • Vary-header awareness in cache_page.
cacheredisperformancemiddleware