Oct 26, 20268 min read

How to Add Redis to an Axum App

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:

  • How the client is pooled and shared with handlers.
  • Which updates must be atomic.
  • Which data may be evicted and which must persist.
  • Where Redis runs locally and in production.

Redis options for Axum at a glance

CapabilityEncoreredis-rs with deadpool-redisfred
Client setupstatic declaration, no URLPool built at startup, passed as statePool built at startup, passed as state
Typed commandsPer data type: string(), sorted_set(), generic()AsyncCommands traitPer-command traits
Transactionstransaction() and exec()redis::pipe().atomic()multi() and exec()
Cache or persistentDatabase::new or .persistent()Server configurationServer configuration
Local Redisencore run starts itYou run itYou run it
Production instanceProvisioned in your AWS or GCP accountYou provision itYou provision it

Encore: Redis for your existing Axum app

Encore

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.

Encore declares infrastructure in code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in code, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

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:

~/shop
$ encore run$ curl localhost:4000/products/7{"id":7,"name":"Product 7","cached":false}$ curl localhost:4000/products/7{"id":7,"name":"Product 7","cached":true}$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " -X POST localhost:4000/login/ada; done200 200 200 200 200 429$ curl localhost:4000/leaderboard[["ada",105.0],["linus",95.0],["grace",55.0],["bob",50.0]]

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.

Good to know

The persistent() flag decides whether keys may be evicted, so put anything that cannot be rebuilt in a persistent database.

Try Encore →

redis-rs with deadpool-redis

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(); }

Good to know

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

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) }

Good to know

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.

Redis in production

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.

How to choose

  • Encore if you want Redis running locally and in your AWS or GCP account from static declarations, with cache and persistent databases kept apart.
  • redis-rs with deadpool-redis for the widest ecosystem and direct control over every command.
  • fred for Cluster or Sentinel deployments that need reconnection handled for you.

Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.

Frequently asked questions

How do I add Redis to an 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.

Which Redis crate should I use with Axum?

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.

How do I make INCR and EXPIRE atomic in Rust?

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.

What is the difference between a cache and a persistent Redis database?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders