Caching, rate limiting and leaderboards in Axum 0.8 with Encore, redis-rs with deadpool-redis, or fred, plus atomic transactions, local Redis and production on ElastiCache or Memorystore.
Axum gives Rust services fast handlers, and Redis is usually the next thing added for caching, rate limits and counters. The client code is short, and most of the effort goes into pooling, atomic updates and running Redis both on every developer's machine and as a private instance in production.
This guide uses Axum 0.8 with its /{param} path syntax and adds Redis with Encore, redis-rs with deadpool-redis, and fred. Each version builds a read-through cache, a login rate limit and a leaderboard.
What you need to decide:
Encore combines Redis databases declared in your Axum code with the servers behind them, starting a local Redis under encore run and provisioning ElastiCache on AWS or Memorystore on GCP when you deploy. It is an infrastructure SDK added to an existing app as the encore-dev crate, and the Axum router, extractors and middleware stay as they are.

The crate goes next to Axum 0.8 in Cargo.toml:
[dependencies]
encore-dev = "0.1"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
Two databases are declared as statics. CACHE may evict keys when memory runs low, which suits data that can be rebuilt, and SCORES is persistent, so the leaderboard survives restarts and memory pressure:
use axum::{extract::Path, http::StatusCode, routing::{get, post}, Json, Router};
use encore::storage::redis::Database;
use serde::{Deserialize, Serialize};
// A cache: keys may be evicted when memory runs low.
static CACHE: Database = Database::new("cache");
// Kept across restarts and never evicted.
static SCORES: Database = Database::new("scores").persistent();
The product handler reads the key and returns the cached copy on a hit. On a miss it loads the product, serializes it and writes it back with a 60 second expiry:
#[derive(Serialize, Deserialize)]
struct Product {
id: u32,
name: String,
cached: bool,
}
// Stands in for a slow database query or upstream API call.
async fn load_product(id: u32) -> Product {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
Product { id, name: format!("Product {id}"), cached: false }
}
async fn get_product(Path(id): Path<u32>) -> Result<Json<Product>, StatusCode> {
let key = format!("product:{id}");
let hit: Option<String> = CACHE
.string()
.get(&key)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if let Some(json) = hit {
let mut product: Product =
serde_json::from_str(&json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
product.cached = true;
return Ok(Json(product));
}
let product = load_product(id).await;
let json = serde_json::to_string(&product).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Expires after 60 seconds.
CACHE
.string()
.set_ex(&key, json, 60)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(product))
}
The login rate limit counts attempts per user and minute. INCR and EXPIRE run in one transaction, so a counter can never be left without an expiry, and the handler returns 429 after five attempts:
// Allows 5 login attempts per user in each one-minute window.
async fn login(Path(user): Path<String>) -> StatusCode {
let minute = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() / 60)
.unwrap_or(0);
let key = format!("login-attempts:{user}:{minute}");
// INCR and EXPIRE run together, so no counter is left without an expiry.
let mut tx = CACHE.transaction();
tx.string().incr(&key, 1);
tx.generic().expire(&key, 60);
let result: Result<(i64, bool), _> = tx.exec().await;
match result {
Ok((attempts, _)) if attempts > 5 => StatusCode::TOO_MANY_REQUESTS,
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
The leaderboard lives in the persistent database, with zincr adding points and zrevrange_withscores returning the top ten. main initializes the runtime, binds the address Encore assigns and shuts down gracefully:
async fn add_score(Path((player, points)): Path<(String, f64)>) -> Result<String, StatusCode> {
let total = SCORES
.sorted_set()
.zincr("leaderboard", &player, points)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(format!("{player}: {total}\n"))
}
async fn leaderboard() -> Result<Json<Vec<(String, f64)>>, StatusCode> {
let top: Vec<(String, f64)> = SCORES
.sorted_set()
.zrevrange_withscores("leaderboard", 0, 9)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(top))
}
#[tokio::main]
async fn main() {
encore::initialize();
let app = Router::new()
.route("/products/{id}", get(get_product))
.route("/login/{user}", post(login))
.route("/scores/{player}/{points}", post(add_score))
.route("/leaderboard", get(leaderboard));
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(encore::shutdown::signal())
.await
.unwrap();
std::process::exit(encore::shutdown::drained().await.code());
}
encore run starts Redis locally for both databases, so the whole flow can be checked before anything exists in the cloud:
The cache miss takes the 200 milliseconds of the simulated lookup and the hit about 5, which the local development dashboard shows per request. On deploy, Encore provisions ElastiCache or Memorystore in a private network in your own cloud account, with the eviction setting each declaration asks for, and instance sizes can be set per environment.
An agent adding a cache to another handler declares it in the same file and sees the hit locally, and the deploy creates the instance. Cloud access sits with Encore's IAM role, not the agent.
The persistent() flag decides whether keys may be evicted, so put anything that cannot be rebuilt in a persistent database.
redis-rs is the most used Rust client, and deadpool-redis adds an async pool. The pool goes into Axum state, and pipe().atomic() gives the same transaction as above:
use axum::{extract::{Path, State}, http::StatusCode, routing::post, Router};
use deadpool_redis::{Config, Pool, Runtime};
async fn login(State(pool): State<Pool>, Path(user): Path<String>) -> StatusCode {
let Ok(mut conn) = pool.get().await else {
return StatusCode::SERVICE_UNAVAILABLE;
};
let key = format!("login-attempts:{user}");
let result: redis::RedisResult<(i64, bool)> = redis::pipe()
.atomic()
.incr(&key, 1)
.expire(&key, 60)
.query_async(&mut conn)
.await;
match result {
Ok((attempts, _)) if attempts > 5 => StatusCode::TOO_MANY_REQUESTS,
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[tokio::main]
async fn main() {
let url = std::env::var("REDIS_URL").unwrap_or("redis://127.0.0.1:6379".into());
let pool = Config::from_url(url).create_pool(Some(Runtime::Tokio1)).unwrap();
let app = Router::new().route("/login/{user}", post(login)).with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Keep the redis and deadpool-redis versions in step (this example builds with deadpool-redis 0.22 and redis 0.32), since deadpool depends on a specific redis-rs release and mismatched versions fail to compile with confusing trait errors. Calling EXPIRE on every attempt resets the window, so this version blocks a user for a minute after their last attempt, which is often what you want for logins.
fred is a fully async client with pooling, reconnection and cluster support built in, and a command per trait:
use fred::prelude::*;
async fn connect() -> Result<Pool, Error> {
let config = Config::from_url("redis://127.0.0.1:6379")?;
let pool = Builder::from_config(config).build_pool(4)?;
pool.init().await?;
Ok(pool)
}
async fn cached_name(pool: &Pool, id: u32) -> Result<String, Error> {
let key = format!("product:{id}");
if let Some(name) = pool.get::<Option<String>, _>(&key).await? {
return Ok(name);
}
let name = format!("Product {id}");
pool.set::<(), _, _>(&key, name.as_str(), Some(Expiration::EX(60)), None, false).await?;
Ok(name)
}
fred suits services that talk to Redis Cluster or Sentinel, where reconnection and topology changes matter. Its API changed between major versions, so pin the version and check examples against the docs for that release.
Cache and persistent data want different server settings. The Redis eviction documentation describes the maxmemory-policy options, and a cache usually runs allkeys-lru while a database for leaderboards runs noeviction. Both ElastiCache and Memorystore run inside a private network, so the service needs a route and a security rule to reach them, and on Memorystore that also means connecting through a VPC network the service is attached to. With Encore, those settings and network rules come from the declarations, and cloud access sits with Encore's IAM role in your account.
static declarations, with cache and persistent databases kept apart.Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.
Create a pooled async client once, share it with handlers through state or a static, and call Redis from the handlers with errors mapped to status codes. With Encore, declare static CACHE: Database = Database::new("cache") from encore::storage::redis, read with CACHE.string().get(&key).await and write with CACHE.string().set_ex(&key, json, 60).await. encore run starts a local Redis for it, and on deploy Encore provisions ElastiCache or Memorystore in your own cloud account.
redis-rs is the most widely used client and pairs with deadpool-redis or bb8-redis for pooling. fred is a fully async client with built-in pooling, reconnection and cluster support.
Run both commands in a MULTI and EXEC transaction, so a crash between them cannot leave a counter without an expiry. In redis-rs that is redis::pipe().atomic(), and with Encore it is CACHE.transaction() followed by tx.exec().await.
A cache may evict keys under memory pressure, which is fine for data you can rebuild. Leaderboards, counters you bill on and other data you cannot rebuild need a database configured not to evict, which Encore declares with Database::new("scores").persistent().
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.