Caching with Redis
In Storage with Postgres we made Snip durable: every link
lives in a links table, and the redirect handler runs a SELECT to find the original URL.
That works. It’s also doing the same database read, over and over, for every popular link.
This chapter adds a cache so the hot path stops hammering Postgres — and, crucially, we’ll measure the win in the next chapter.
:::tip WHY before HOW
Don’t cache because “caching is fast.” Cache because you have a read-heavy workload where the
same answers are requested again and again, and the freshest answer isn’t worth a database round-trip
every time. A URL shortener is the textbook case: one POST /shorten, then potentially millions of
GET /{code} redirects — all returning the identical URL.
:::
For the bigger picture, read Caching and Caching Strategies — this chapter is the same ideas, in Rust, on a real path.
Why the redirect path, and only the redirect path
Section titled “Why the redirect path, and only the redirect path”Look at what each Snip endpoint does:
POST /shorten— writes a brand-new row. There’s nothing to cache; it’s a one-time event.GET /{code}— reads a URL that never changes once created. Read constantly. Perfect to cache.
That asymmetry is the whole game. Writes are rare and unique; reads are frequent and repetitive. We cache the read and leave the write alone. If you only ever cache one thing in a read-heavy service, cache the thing on the path that gets hit the most.
Setting up the Redis connection
Section titled “Setting up the Redis connection”At startup, main opens a Redis client and wraps it in a ConnectionManager:
let client = redis::Client::open(redis_url).expect("valid redis url");let cache = redis::aio::ConnectionManager::new(client) .await .expect("connect to redis");Two distinct objects, and the difference matters:
redis::Client::open(redis_url)just parses the URL (redis://localhost:6379). It does no network I/O yet — it’s cheap, and it fails only if the URL is malformed. That’s why the message is"valid redis url".ConnectionManager::new(client).awaitactually connects, and gives you back a handle that is cloneable and multiplexed. One TCP connection is shared across many concurrent tasks, and if it drops, the manager transparently reconnects. This is what we store inAppState.
#[derive(Clone)]struct AppState { db: PgPool, cache: redis::aio::ConnectionManager, base_url: String, metrics: Arc<Metrics>,}:::note Why ConnectionManager and not a raw connection
A raw Redis connection can only do one thing at a time, so sharing it across requests would mean
locking. ConnectionManager is built to be clone()d freely: every handler clones it and issues
commands concurrently over the same underlying multiplexed connection. No connection pool to
manage, no lock contention. It’s the right default for an async service.
:::
The cache-aside pattern
Section titled “The cache-aside pattern”“Cache-aside” (also called lazy loading) means: the application — not the cache — is in charge of the database. On a read you ask the cache first; on a miss you read the database yourself, then put the answer back into the cache so the next read hits. The cache sits aside the database; it doesn’t sit in front of it.
Here is the entire redirect handler:
async fn redirect( State(state): State<AppState>, Path(code): Path<String>,) -> Result<Redirect, StatusCode> { state.metrics.redirects.fetch_add(1, Ordering::Relaxed); let cache_key = format!("url:{code}"); let mut conn = state.cache.clone();
// Cache-aside: check Redis first, fall back to Postgres. let cached: Option<String> = conn.get(&cache_key).await.unwrap_or(None); let url = if let Some(url) = cached { state.metrics.cache_hits.fetch_add(1, Ordering::Relaxed); url } else { state.metrics.cache_misses.fetch_add(1, Ordering::Relaxed); let row: Option<(String,)> = sqlx::query_as("SELECT url FROM links WHERE code = $1") .bind(&code) .fetch_optional(&state.db) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match row { Some((url,)) => { let _: Result<(), _> = conn.set_ex(&cache_key, &url, 3600u64).await; url } None => { state.metrics.not_found.fetch_add(1, Ordering::Relaxed); return Err(StatusCode::NOT_FOUND); } } };
// Fire-and-forget: enqueue a click event for the worker to count. let _: Result<(), _> = conn.rpush("clicks", &code).await;
Ok(Redirect::temporary(&url))}Let’s walk it.
The key. let cache_key = format!("url:{code}"); We namespace cache keys with a prefix
(url:). Redis is one big flat keyspace shared by the cache, the rate limiter (rl:), and the
click queue (clicks). Prefixes keep them from colliding and make the keyspace readable when you’re
poking around with redis-cli.
Get a connection. let mut conn = state.cache.clone(); — cheap, as promised. We need mut
because the command methods borrow the connection mutably.
Read the cache.
let cached: Option<String> = conn.get(&cache_key).await.unwrap_or(None);The conn.get(...) method comes from the redis::AsyncCommands trait, which is brought into scope
with use redis::AsyncCommands; at the top of the file. That trait is what gives a connection the
friendly, typed get / set_ex / incr / rpush methods instead of raw command strings.
The type annotation Option<String> is doing real work. Redis GET on a missing key returns nil,
and the Rust client maps that to None; a present key deserializes into Some(string). So
Option<String> cleanly models hit vs. miss at the type level. The trailing .unwrap_or(None)
says: if the Redis call itself errors (network blip, Redis down), treat it as a miss rather than
failing the request. The cache is an optimization, not a dependency — if it’s unavailable, Snip
degrades to “always read Postgres,” which is correct, just slower. (We revisit this idea in
Resilience.)
Hit: Some(url) → bump cache_hits, use the URL. Done. No Postgres call at all.
Miss: None → bump cache_misses, then read Postgres with fetch_optional (returns
Option<(String,)> — the (String,) is a one-column row tuple).
-
Row found → populate the cache and return the URL:
let _: Result<(), _> = conn.set_ex(&cache_key, &url, 3600u64).await;set_exisSETwith an expiry: storeurl:{code}→ the URL, and have Redis auto-delete it in3600seconds (one hour). We bind the result tolet _: Result<(), _> = ...because, again, a failed cache write shouldn’t fail the redirect — we just move on. The next request for this code will hit the cache and skip Postgres entirely. -
No row → bump
not_found, return404.
The last line, conn.rpush("clicks", &code), pushes the code onto a Redis list for the background
worker to tally later — that’s the queue we build in
Async Work with a Queue. Note it’s also fire-and-forget: a
redirect should be fast, so counting the click is somebody else’s problem.
The TTL trade-off: speed vs. staleness
Section titled “The TTL trade-off: speed vs. staleness”That 3600 is the single most important number in this file, so let’s be honest about what it buys
and costs.
- What it buys: for one hour after the first read, every redirect for that code is served from memory in Redis — no Postgres round-trip. On a hot link that’s the difference between thousands of database queries and one.
- What it costs: staleness. If a link’s URL changed in Postgres (Snip doesn’t support edits, but imagine it did), the cache would keep serving the old URL for up to an hour. The cache and the source of truth can disagree for the length of the TTL.
For Snip this is a great trade: URLs are effectively immutable, so the staleness window is harmless, and a 1-hour TTL also acts as a safety valve — even cached entries eventually expire and get re-read, so the cache can never drift forever. Picking a TTL is exactly this judgment call: how wrong are you willing to be, and for how long, in exchange for how much speed? See Caching Strategies for the full menu (TTL, write-through, explicit invalidation).
:::caution Cache stampede With cache-aside, the moment a hot key expires (or is first requested), every concurrent request for it misses at once and they all stampede the database with the same query. Snip’s traffic is modest so we let it ride, but at scale this is a real failure mode — read Cache Stampede & the Thundering Herd for the fixes (request coalescing, locks, jittered TTLs). Worth knowing the dragon exists before you meet it. :::
How the metrics prove it worked
Section titled “How the metrics prove it worked”We can’t claim the cache helps; we have to see it. Every path through redirect increments an
atomic counter, and /metrics prints them:
async fn metrics_handler(State(state): State<AppState>) -> impl IntoResponse { let m = &state.metrics; let body = format!( "shortens {}\nredirects {}\ncache_hits {}\ncache_misses {}\nnot_found {}\n", m.shortens.load(Ordering::Relaxed), m.redirects.load(Ordering::Relaxed), m.cache_hits.load(Ordering::Relaxed), m.cache_misses.load(Ordering::Relaxed), m.not_found.load(Ordering::Relaxed), ); (StatusCode::OK, body)}The counters are AtomicU64 so many concurrent requests can fetch_add safely without a lock.
Ordering::Relaxed is fine here: we only care that the count is eventually correct, not that it’s
ordered relative to other memory operations. The ratio you want to watch is cache_hits /
(cache_hits + cache_misses) — the hit rate. First request to a code: a miss. Every request
after, within the hour: a hit. We dig deeper into reading numbers like these in
Observability.
Try it
Section titled “Try it”Shorten a URL, then hit the same short code twice and watch a cache hit appear:
# Shorten and grab the code (jq optional, just for readability)curl -s -X POST localhost:8080/shorten \ -H 'content-type: application/json' \ -d '{"url":"https://www.rust-lang.org"}'# → {"code":"V1StGXR","short_url":"http://0.0.0.0:8080/V1StGXR"}
# First redirect → cache MISS (Snip reads Postgres, then populates Redis)curl -s -o /dev/null localhost:8080/V1StGXR
# Second redirect → cache HIT (served from Redis, no Postgres)curl -s -o /dev/null localhost:8080/V1StGXR
# Now look at the counterscurl -s localhost:8080/metricsYou should see cache_misses 1 and cache_hits 1. Hit the code a few more times and only
cache_hits climbs. (Optional: redis-cli TTL url:V1StGXR shows the seconds remaining before that
entry expires — watch it count down from ~3600.)
Check your understanding
Section titled “Check your understanding”- What’s the difference between
redis::Client::openandConnectionManager::new, and which one actually opens a network connection? - In the cache-aside flow, who writes to the cache, and when — on the read path or the write path?
- Why is the Redis read typed as
Option<String>, and what doesNonerepresent? - The cache
getends in.unwrap_or(None)and the cacheset_exis bound tolet _. What design decision does that reflect about Snip’s dependency on Redis? - We use a 3600-second TTL. Name one thing it buys and one thing it costs. Why is the trade acceptable for a URL shortener specifically?
Show answers
redis::Client::openonly parses the URL — it does no network I/O and fails only if the URL is malformed (hence the"valid redis url"message).ConnectionManager::new(client).awaitis the one that actually connects, returning a cloneable, multiplexed handle that transparently reconnects if the connection drops.- The application writes to the cache (cache-aside, a.k.a. lazy loading), and it does so on the read path — specifically on a miss: after reading the value from Postgres, the handler calls
set_exto populate the cache so the next read hits. The write path (POST /shorten) touches only Postgres; it never warms the cache. - Redis
GETon a missing key returns nil, which the client maps toNone, while a present key deserializes toSome(string)— soOption<String>cleanly models hit vs. miss at the type level.Nonerepresents a cache miss: the key isn’t there, so the handler must fall back to Postgres. - It reflects that the cache is an optimization, not a dependency.
.unwrap_or(None)treats a Redis read error as a miss, and binding theset_exto_ignores a failed cache write — so if Redis is down, Snip degrades to “always read Postgres,” which is correct, just slower. A redirect is never failed because the cache misbehaved. (Revisited in Resilience.) - Buys: for one hour after the first read, every redirect for that code is served from memory — thousands of DB queries collapse to one. Costs: staleness — if the URL changed in Postgres, the cache would serve the old value for up to an hour. It’s acceptable for a URL shortener because URLs are effectively immutable, so the staleness window is harmless, and the TTL doubles as a safety valve so the cache can never drift forever.