When in-memory maps stop scaling
You build a session store for a web app. It works perfectly on your laptop. You deploy to three instances behind a load balancer. Users get logged out randomly because their session lives in the memory of instance two, but the next request hits instance one. You restart a container and lose every active session. You need a shared, fast, persistent store that survives restarts and scales horizontally. Redis is the standard answer. Connecting it to Rust requires bridging two different worlds. Rust demands strict compile-time types. Redis speaks a flexible, string-based wire protocol over TCP. The redis crate sits between them and handles the translation.
Stop treating your application memory like a database. Offload the shared state.
How the crate bridges two worlds
Redis stores everything as bytes. It does not care if a value represents a number, a list, or a JSON blob. It just moves bytes from one side of the network to the other. Rust refuses to compile if you try to treat a string slice as an integer. The redis crate resolves this mismatch through traits. When you send a command, the crate converts your Rust types into Redis protocol frames using the ToRedisArgs trait. When you read a response, it converts the raw protocol frames back into Rust types using the FromRedisValue trait.
Think of it like a customs broker. You hand them a Rust struct. They pack it into a standard shipping container. They ship it across the border. They unpack it back into a Rust struct on the other side. You never touch the raw bytes yourself. The crate handles serialization, network I/O, and protocol parsing. You write idiomatic Rust and let the abstraction do the heavy lifting.
The minimal async setup
Modern Rust applications run on async runtimes. Blocking a thread while waiting for a network response wastes resources and kills throughput. The redis crate provides async support through optional features. You need to enable the runtime you are already using. Tokio is the most common choice.
[dependencies]
redis = { version = "0.27", features = ["tokio-comp"] }
tokio = { version = "1", features = ["full"] }
The tokio-comp feature pulls in the necessary adapters so the crate can integrate with Tokio's event loop. Without it, the async methods simply do not exist.
use redis::Client;
/// Demonstrates a basic async SET and GET operation.
async fn run_basic_example() -> redis::RedisResult<()> {
// Parse the URL and create a client configuration.
// No network connection is opened yet.
let client = Client::open("redis://127.0.0.1/")?;
// Open an async TCP connection to the Redis server.
// This performs the handshake and returns a connection handle.
let mut con = client.get_async_connection().await?;
// Build a SET command, attach arguments, and execute it.
// query_async sends the command and deserializes the response.
redis::cmd("SET").arg("my_key").arg("my_value").query_async(&mut con).await?;
// Retrieve the value and let the crate infer the Rust type.
// The type annotation tells FromRedisValue how to parse the bytes.
let val: String = redis::cmd("GET").arg("my_key").query_async(&mut con).await?;
println!("Value: {val}");
Ok(())
}
#[tokio::main]
async fn main() -> redis::RedisResult<()> {
run_basic_example().await
}
Run it. Watch the value appear and disappear. You just crossed the network boundary without writing a single byte of protocol code.
What happens under the hood
The Client::open call only parses the URL. It validates the scheme, extracts the host and port, and stores authentication credentials if you provided them. It does not touch the network. This design lets you construct clients at startup and reuse them across your application.
Calling get_async_connection opens the TCP socket. The runtime schedules the connection attempt. The crate sends the Redis protocol handshake. If the server requires authentication, the crate handles the AUTH command automatically if you included the password in the URL. The method returns a AsyncConnection that wraps the socket and a buffer for outgoing commands.
When you call query_async, the crate serializes the command into Redis Request Protocol (RESP) format. It writes the bytes to the socket. The runtime waits for the server to respond. The crate reads the response, parses the RESP frame, and calls FromRedisValue on the target type. The ? operator propagates any failure as a redis::RedisError. The error type wraps network failures, protocol violations, and type conversion mistakes in a single enum.
The crate handles the handshake. You handle the business logic.
Storing real application data
Real applications rarely store plain strings. You need to persist user profiles, configuration objects, or cached query results. Fighting Redis's native types for complex data leads to fragile code. The community convention is to serialize complex structures to JSON and store them as strings. JSON is universal, human-readable, and plays nicely with Rust's type system.
[dependencies]
redis = { version = "0.27", features = ["tokio-comp"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use redis::Client;
use serde::{Deserialize, Serialize};
/// Represents a cached user profile.
#[derive(Debug, Serialize, Deserialize)]
struct UserProfile {
id: u64,
username: String,
role: String,
}
/// Persists and retrieves a structured object using JSON serialization.
async fn run_struct_example() -> redis::RedisResult<()> {
let client = Client::open("redis://127.0.0.1/")?;
let mut con = client.get_async_connection().await?;
// Create the data structure you want to store.
let user = UserProfile {
id: 42,
username: "rustacean".to_string(),
role: "admin".to_string(),
};
// Serialize to JSON. Redis only understands bytes or strings.
// Converting here keeps the network payload predictable.
let json = serde_json::to_string(&user)?;
// Store the JSON string under a typed key prefix.
// Prefixing keys helps with cache invalidation and debugging.
redis::cmd("SET").arg("user:42").arg(&json).query_async(&mut con).await?;
// Fetch the raw string from Redis.
let stored_json: String = redis::cmd("GET").arg("user:42").query_async(&mut con).await?;
// Deserialize back into the Rust struct.
// This is where type safety returns to your application.
let loaded_user: UserProfile = serde_json::from_str(&stored_json)?;
println!("Loaded: {:?}", loaded_user);
Ok(())
}
#[tokio::main]
async fn main() -> redis::RedisResult<()> {
run_struct_example().await
}
A quick convention note: developers often write Rc::clone(&data) instead of data.clone() to signal that they are cloning a reference count, not the underlying data. The same principle applies here. Explicitly calling serde_json::to_string makes it obvious that you are crossing a serialization boundary. Implicit conversions hide the cost.
Serialize to JSON when the structure grows. Don't fight the type system with raw Redis hashes.
Where the friction appears
Redis is fast, but the network is not free. Misusing the async client creates subtle performance traps and compile-time headaches.
If you call get_async_connection inside a tight loop, you open a new TCP socket for every request. The handshake adds latency. The operating system exhausts file descriptors. The compiler will not stop you, but your metrics will. Store the Client struct in your application state. Call get_async_connection per request, or better yet, use get_multiplexed_async_connection. The multiplexed connection reuses a single TCP socket for multiple concurrent commands. It pipelines requests automatically and dramatically reduces latency.
Type mismatches are the second common trap. Redis stores everything as strings. If you store "100" and try to retrieve it as i32, the crate converts it successfully. If you store "hello" and try to retrieve it as i32, the runtime panics or returns a TypeError. The compiler cannot catch this because the conversion happens at runtime. If you accidentally pass a String where the crate expects a &str in a command builder, you might hit E0277 (trait bound not satisfied). The crate requires types that implement ToRedisArgs. Most standard types do, but custom enums or nested structs do not. You must serialize them first.
Blocking the async runtime is the third trap. If you call .await inside a synchronous function, the code will not compile. If you accidentally spawn a blocking thread to wait for Redis, you defeat the purpose of async. Keep all Redis calls inside async functions. Use tokio::task::spawn_blocking only for CPU-heavy work, never for I/O.
Profile your connection overhead before optimizing your query logic. The network is always slower than you think.
Choosing the right connection strategy
Picking the right setup depends on your runtime, your concurrency model, and your latency requirements. Match the tool to the architecture.
Use the redis crate with tokio-comp when you need async I/O and are already running a Tokio runtime. Use the redis crate with async-std-comp when your project relies on async-std and you want to avoid mixing runtimes. Use the redis crate without async features when you are writing a synchronous CLI tool or a simple script where blocking I/O is acceptable and simpler. Use get_multiplexed_async_connection when your application handles concurrent requests and you need to reuse a single TCP connection for multiple in-flight commands. Use a connection pool like r2d2 or bb8 when you are building a web server with many independent request handlers and you want to distribute load across multiple TCP connections. Reach for raw TCP sockets only when you are building a custom Redis proxy, a benchmarking tool, or need to bypass the crate's serialization entirely.
Match the runtime to your project. Force-fitting an async client into a sync codebase creates more problems than it solves.