Oct 27, 20266 min read

How to Add Redis to an Actix Web App

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:

  • How the connection is shared across Actix workers.
  • How keys expire and how writes invalidate them.
  • Whether rate limit counters are updated atomically.
  • Where Redis runs locally and in production.

Redis options for Actix Web at a glance

CapabilityEncoreredis cratedeadpool-redisactix-limitation
UseCaching, counters, any key typeAny Redis commandPooled redis connectionsRate limiting middleware
SetupStatic Database::new("cache")Client and connection managerPool in web::DataLimiter in web::Data
Transactionstransaction()...exec()redis::pipe().atomic()Same as redis crateBuilt in
Local Redisencore run starts itRun your ownRun your ownRun your own
ProductionElastiCache or Memorystore in your accountAny hosted RedisAny hosted RedisAny hosted Redis

Encore: Redis for your existing Actix Web app

Encore

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:

~/shop
$ encore run$ curl -si localhost:4000/products/7 | grep x-cachex-cache: miss$ curl -si localhost:4000/products/7 | grep x-cachex-cache: hit

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.

Good to know

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.

Try Encore →

The redis crate

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 }

Good to know

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

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

Good to know

Pool size limits concurrent commands per worker, so size it against the Redis connection limit times the number of instances.

actix-limitation

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

Good to know

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.

How to choose

  • Encore if you want Redis running locally and as ElastiCache or Memorystore in your own account from the same code.
  • The redis crate with ConnectionManager for direct control in an app that already has a Redis per environment.
  • deadpool-redis when you need pooled, dedicated connections.
  • actix-limitation for IP-based rate limits as middleware.

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

Frequently asked questions

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

How do I rate limit an Actix Web route with Redis?

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().

Should Actix Web share one Redis connection across workers?

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.

What happens to my Actix Web app if Redis goes down?

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.

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