Caching responses and rate limiting logins in an Actix Web 4 app with Encore, the redis crate, deadpool-redis or actix-limitation, with atomic counters and a Redis that runs locally.
Actix Web apps add Redis for the usual reasons: a slow lookup worth caching, or a login route that needs a rate limit. That brings a Redis client and pool shared across Actix workers, a Redis for each developer and a managed Redis in production with its own connection settings.
This guide adds Redis to an Actix Web 4 app with Encore, the redis crate, deadpool-redis and actix-limitation. The examples cache a product for 60 seconds, invalidate it on demand and limit logins to five per minute.
What you need to decide:
Encore combines Redis databases declared in your Actix Web code with the servers behind them, starting Redis locally under encore run and provisioning ElastiCache on AWS or Memorystore on GCP in your own account on deploy. It is an infrastructure SDK, the encore-dev crate, added to an existing app, and App, extractors and middleware stay as they are.
The cache is a static that handlers call directly:
use actix_web::{get, post, web, HttpResponse, Responder};
use encore::storage::redis::Database;
use serde::{Deserialize, Serialize};
// A cache: keys may be evicted under memory pressure.
static CACHE: Database = Database::new("cache");
#[derive(Serialize, Deserialize)]
struct Product {
id: u32,
name: String,
}
// Stands in for a slow query or upstream call.
async fn load_product(id: u32) -> Product {
Product { id, name: format!("Product {id}") }
}
The product handler reads through the cache, stores misses for 60 seconds with set_ex and marks each response with an x-cache header:
#[get("/products/{id}")]
async fn get_product(path: web::Path<u32>) -> impl Responder {
let id = path.into_inner();
let key = format!("product:{id}");
match CACHE.string().get::<Option<String>>(&key).await {
Ok(Some(json)) => {
return HttpResponse::Ok()
.insert_header(("x-cache", "hit"))
.content_type("application/json")
.body(json)
}
Ok(None) => {}
Err(_) => return HttpResponse::InternalServerError().finish(),
}
let product = load_product(id).await;
let json = serde_json::to_string(&product).unwrap();
if CACHE.string().set_ex(&key, json.clone(), 60).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok()
.insert_header(("x-cache", "miss"))
.content_type("application/json")
.body(json)
}
#[post("/products/{id}/invalidate")]
async fn invalidate(path: web::Path<u32>) -> impl Responder {
match CACHE.generic().del(format!("product:{}", path.into_inner())).await {
Ok(removed) => HttpResponse::Ok().json(serde_json::json!({ "removed": removed })),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
The login limit runs INCR and EXPIRE in one MULTI/EXEC transaction, so a counter cannot exist without its expiry:
#[derive(Deserialize)]
struct Login {
email: String,
}
#[post("/login")]
async fn login(body: web::Json<Login>) -> impl Responder {
let key = format!("login-attempts:{}", body.email);
// INCR and EXPIRE in one MULTI/EXEC, so a counter never outlives its window.
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 => HttpResponse::TooManyRequests().finish(),
Ok((attempts, _)) => HttpResponse::Ok().json(serde_json::json!({ "attempts": attempts })),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
main initializes Encore and binds to the address it assigns:
use actix_web::{App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
encore::initialize();
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
HttpServer::new(|| App::new().service(get_product).service(invalidate).service(login))
.bind(addr)?
.run()
.await
}
Under encore run, the first read misses, the second hits, and the sixth login in a minute gets a 429:
Each request's trace shows the Redis commands next to the handler. On deploy, Encore provisions ElastiCache on AWS or Memorystore on GCP in your own account and passes the connection to the service.
An agent can add a cache or a counter as another static and verify the hit in the local trace.
Because EXPIRE runs on every attempt, the window restarts with each login; for a fixed window, set the expiry only when INCR returns 1. Cache values can disappear at any time, so treat a miss as normal.
The redis crate with a ConnectionManager gives a reconnecting, multiplexed connection you can clone into each worker:
use actix_web::{get, web, App, HttpResponse, HttpServer, Responder};
use redis::{aio::ConnectionManager, AsyncCommands};
#[get("/products/{id}")]
async fn get_product(redis: web::Data<ConnectionManager>, path: web::Path<u32>) -> impl Responder {
let mut conn = redis.get_ref().clone();
let key = format!("product:{}", path.into_inner());
let cached: Option<String> = conn.get(&key).await.unwrap_or(None);
if let Some(json) = cached {
return HttpResponse::Ok().content_type("application/json").body(json);
}
let json = r#"{"id":7}"#.to_string();
let _: () = conn.set_ex(&key, &json, 60).await.unwrap_or(());
HttpResponse::Ok().content_type("application/json").body(json)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let client = redis::Client::open(std::env::var("REDIS_URL").unwrap()).unwrap();
let manager = ConnectionManager::new(client).await.unwrap();
HttpServer::new(move || App::new().app_data(web::Data::new(manager.clone())).service(get_product))
.bind("0.0.0.0:8080")?
.run()
.await
}
Atomic counters use redis::pipe().atomic(). You provide REDIS_URL per environment and a Redis for local development, and TLS to managed Redis needs the crate's TLS feature.
deadpool-redis pools connections when you need dedicated ones, for example for blocking commands:
use deadpool_redis::{redis::AsyncCommands, Config, Pool, Runtime};
fn make_pool() -> Pool {
let cfg = Config::from_url(std::env::var("REDIS_URL").unwrap());
cfg.create_pool(Some(Runtime::Tokio1)).unwrap()
}
async fn hits(pool: &Pool) -> i64 {
let mut conn = pool.get().await.unwrap();
conn.incr("hits", 1).await.unwrap()
}
Pool size limits concurrent commands per worker, so size it against the Redis connection limit times the number of instances.
actix-limitation is a Redis-backed rate limiter for Actix Web, configured once and checked in handlers or middleware:
use std::time::Duration;
use actix_limitation::Limiter;
fn limiter() -> Limiter {
Limiter::builder("redis://127.0.0.1")
.key_by(|req| req.peer_addr().map(|a| a.ip().to_string()))
.limit(5)
.period(Duration::from_secs(60))
.build()
.unwrap()
}
It keys by whatever you return from key_by, often the client IP or a session value. Keying by a field in the JSON body, like the login email, is easier in a handler as in the Encore example.
ConnectionManager for direct control in an app that already has a Redis per environment.Rust support in Encore is in beta, and you can sign up for access to try it with your Actix Web app.
Create one client or pool at startup and use it from handlers. With Encore, declare static CACHE: Database = Database::new("cache") from encore::storage::redis, then call CACHE.string().get, CACHE.string().set_ex and CACHE.generic().del in handlers. encore run starts Redis locally, and on deploy it becomes ElastiCache on AWS or Memorystore on GCP.
Increment a counter per user and set its expiry in the same MULTI/EXEC transaction, then reject the request when the count passes the limit. With Encore, build the transaction with CACHE.transaction(), queue string().incr and generic().expire, and call exec().
Use a pool or a multiplexed connection rather than one connection per request. Actix runs a separate worker per CPU core, so create the pool before HttpServer::new and clone it into each worker with web::Data.
Cache reads should fall back to the source, and the handler should decide whether a failed rate-limit check allows or rejects the request. The examples here return 500 so the failure is visible in testing.
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.