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)
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
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:
umbral-cache = { version = "0.0.1", features = ["redis"] }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:
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):
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):
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.
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-Cookieheader (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
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
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
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_sethelper.incr/decratomic ops.- Memcached backend.
- Distributed cache invalidation (tag-based).
- ETag/304 conditional caching in
cache_page. Vary-header awareness incache_page.