It’s 2025, and in the world of backend development, latency is the new downtime. As Rust continues to dominate the systems programming landscape—powering everything from high-frequency trading platforms to cloud-native microservices—the expectation for sub-millisecond response times has never been higher.
If you are building a Rust application that talks to a database or an external API, you eventually hit a performance ceiling. That’s where caching comes in. However, simply wrapping a HashMap in a Mutex and calling it a cache is a rookie mistake that leads to contention issues and memory leaks.
In this guide, we will walk through implementing professional-grade caching strategies in Rust. We will move from high-performance in-memory caching using the moka crate to distributed caching with redis, discussing the trade-offs, implementation details, and common pitfalls along the way.
Prerequisites & Environment #
Before we dive into the code, ensure your environment is set up. This guide assumes you are comfortable with Rust’s async ecosystem (Tokio).
- Rust Version: 1.83+ (Stable)
- Packet Manager: Cargo
- IDE: VS Code (with rust-analyzer) or RustRover
- External Service: A running Redis instance (optional, for the second half of the guide).
Let’s set up our project dependencies. We will be using tokio for the runtime, moka for local caching, redis for distributed caching, and serde for serialization.
Cargo.toml:
[package]
name = "rust-caching-pro"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.40", features = ["full"] }
moka = { version = "0.12", features = ["future"] } # High performance caching
redis = { version = "0.27", features = ["tokio-comp", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0" # For easy error handlingStrategy 1: The “Cache-Aside” Pattern #
Before writing code, we need to agree on the flow. The most common and robust pattern for general web applications is Cache-Aside (Lazy Loading).
Here is how the logic flows in a typical read operation:
The application code effectively manages the cache. It checks the cache first; if the data isn’t there, it does the heavy lifting (DB query) and populates the cache for the next requester.
Strategy 2: High-Performance In-Memory Caching #
For single-instance microservices or CLI tools, a local in-memory cache is unbeatable in terms of speed. While std::collections::HashMap is fast, it lacks eviction policies (LRU/LFU), expiration (TTL), and concurrent access optimization.
Enter Moka. It is a heavily optimized caching library for Rust, inspired by Java’s Caffeine and Go’s Ristretto. It handles high concurrency significantly better than a standard Mutex-protected map.
Implementing an Async LRU Cache #
Let’s build a service that simulates fetching user profiles.
use moka::future::Cache;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use std::sync::Arc;
// 1. Define our data model
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UserProfile {
id: u32,
username: String,
email: String,
}
// 2. Simulate a slow database call
async fn fetch_user_from_db(user_id: u32) -> Result<UserProfile, anyhow::Error> {
// Simulate latency
tokio::time::sleep(Duration::from_millis(500)).await;
println!("--> DB HIT: Fetching user {}", user_id);
Ok(UserProfile {
id: user_id,
username: format!("user_{}", user_id),
email: format!("user_{}@example.com", user_id),
})
}
// 3. Our Service Struct
struct UserService {
// Key: User ID (u32), Value: UserProfile
cache: Cache<u32, UserProfile>,
}
impl UserService {
pub fn new() -> Self {
// Configure the cache
let cache = Cache::builder()
// Max 10,000 entries
.max_capacity(10_000)
// Time to live: 30 minutes
.time_to_live(Duration::from_secs(30 * 60))
// Eviction policy happens automatically (TinyLFU)
.build();
Self { cache }
}
pub async fn get_user(&self, user_id: u32) -> Result<UserProfile, anyhow::Error> {
// Moka's `get_with` is powerful: it handles the "Check -> Miss -> Fetch -> Populate"
// atomic flow automatically, preventing cache stampedes.
let user = self.cache.get_with(user_id, async move {
match fetch_user_from_db(user_id).await {
Ok(user) => user,
Err(_) => panic!("DB failure"), // Simplify error handling for demo
}
}).await;
Ok(user)
}
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let service = Arc::new(UserService::new());
// First call: Slow (DB Hit)
let start = std::time::Instant::now();
let _u1 = service.get_user(42).await?;
println!("Request 1 duration: {:?}", start.elapsed());
// Second call: Fast (Cache Hit)
let start = std::time::Instant::now();
let _u2 = service.get_user(42).await?;
println!("Request 2 duration: {:?}", start.elapsed());
Ok(())
}Why get_with Matters
#
In the code above, cache.get_with is crucial. In a naive implementation, if 1,000 requests come in simultaneously for user_id: 42 and the cache is empty, all 1,000 requests might hit the database. This is known as a Thundering Herd or Cache Stampede.
Moka’s get_with ensures that only one request executes the async block (the DB fetch), while the other 999 wait for that result.
Strategy 3: Distributed Caching with Redis #
In-memory caching has a limit: it doesn’t share state across multiple instances of your application. If you have 5 Kubernetes pods, each has its own cold cache. Furthermore, if a pod restarts, the memory is wiped.
For distributed systems, Redis is the industry standard. We will use the redis crate with deadpool or generic connection pooling to handle throughput.
Implementing the Redis Cache Layer #
Here is how to implement the Cache-Aside pattern manually with Redis.
use redis::AsyncCommands;
use serde_json;
struct RedisCache {
client: redis::Client,
}
impl RedisCache {
pub fn new(connection_string: &str) -> Self {
let client = redis::Client::open(connection_string)
.expect("Invalid Redis connection string");
Self { client }
}
pub async fn get_user_distributed(&self, user_id: u32) -> Result<UserProfile, anyhow::Error> {
let key = format!("user:{}", user_id);
let mut conn = self.client.get_multiplexed_async_connection().await?;
// 1. Try to get from Redis
// We use Option<String> because the key might not exist
let cached_json: Option<String> = conn.get(&key).await?;
if let Some(json_str) = cached_json {
println!("<-- REDIS HIT");
let user: UserProfile = serde_json::from_str(&json_str)?;
return Ok(user);
}
// 2. Fetch from DB (Cache Miss)
println!("--> REDIS MISS: Fetching from DB...");
let user = fetch_user_from_db(user_id).await?;
// 3. Serialize and Write to Redis with TTL (Ex: 60 seconds)
let json_str = serde_json::to_string(&user)?;
// Use a pipeline or separate command to set with expiry
let _: () = conn.set_ex(&key, json_str, 60).await?;
Ok(user)
}
}Note: In a production environment, you should use a connection pool (like deadpool-redis or bb8-redis) instead of creating a multiplexed connection on every request or sharing a single client aggressively.
Comparing Caching Approaches #
Choosing the right strategy depends on your infrastructure. Here is a breakdown of when to use what.
| Feature | std::HashMap |
Moka (Local) |
Redis (Distributed) |
|---|---|---|---|
| Speed | Extremely Fast (<10ns) | Very Fast (~100ns) | Network Dependent (1-5ms) |
| Persistence | None | None | Optional (RDB/AOF) |
| Consistency | Local only | Local only | Shared across instances |
| Complexity | Low | Medium | High (Ops overhead) |
| Eviction | Manual implementation | Built-in (TinyLFU) | Built-in (LRU/LFU/TTL) |
| Best For | Configuration, Static data | L1 Cache, Hot items | Session store, Shared state |
Best Practices and Pitfalls #
1. Hybrid Caching (L1 + L2) #
For ultra-high load systems, a hybrid approach is often best.
- Check Local Cache (Moka) -> Return if found.
- Check Remote Cache (Redis) -> Return if found & populate Local.
- Fetch DB -> Populate Redis & Local.
This reduces network traffic to Redis for “hot” keys (e.g., the configuration of a tenant or a viral post).
2. Serialization Overhead #
Using serde_json is convenient, but it can be slow for large objects. If you are caching massive datasets in Redis, consider binary formats like Bincode or Protobuf. They are smaller over the wire and faster to deserialize in Rust.
3. Cache Penetration #
If a user requests an ID that doesn’t exist (e.g., ID -1), your code might bypass the cache and hit the DB every time.
- Solution: Cache the “Empty” result. Store a dummy value in Redis with a short TTL indicating “This ID does not exist.”
4. Versioning #
When you deploy a new version of your UserProfile struct (e.g., adding a field), deserialization of old cached data might fail.
- Solution: Append a version number to your cache keys (e.g.,
v1:user:42) or handle serde defaults gracefully.
Conclusion #
Caching is not just about making things faster; it’s about system resilience. In 2025, using raw HashMaps for caching in Rust is rarely the right choice for web services.
- Use Moka for intelligent, thread-safe, local caching with minimal overhead.
- Use Redis when you need to share data between microservices or preserve state across restarts.
Start with the Cache-Aside pattern demonstrated here. It is predictable and covers 90% of use cases. As you scale, keep an eye on your serialization costs and consider moving to a layered L1/L2 strategy.
Happy coding!
If you found this article helpful, check out our previous deep dive on [Async Rust Patterns for 2025] or subscribe to the Rust DevPro newsletter for more systems engineering content.