Skip to content

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.

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).await actually 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 in AppState.
#[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. :::

“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_ex is SET with an expiry: store url:{code} → the URL, and have Redis auto-delete it in 3600 seconds (one hour). We bind the result to let _: 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, return 404.

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.

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. :::

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.

Shorten a URL, then hit the same short code twice and watch a cache hit appear:

Terminal window
# 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 counters
curl -s localhost:8080/metrics

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

  1. What’s the difference between redis::Client::open and ConnectionManager::new, and which one actually opens a network connection?
  2. In the cache-aside flow, who writes to the cache, and when — on the read path or the write path?
  3. Why is the Redis read typed as Option<String>, and what does None represent?
  4. The cache get ends in .unwrap_or(None) and the cache set_ex is bound to let _. What design decision does that reflect about Snip’s dependency on Redis?
  5. 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
  1. redis::Client::open only 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).await is the one that actually connects, returning a cloneable, multiplexed handle that transparently reconnects if the connection drops.
  2. 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_ex to populate the cache so the next read hits. The write path (POST /shorten) touches only Postgres; it never warms the cache.
  3. Redis GET on a missing key returns nil, which the client maps to None, while a present key deserializes to Some(string) — so Option<String> cleanly models hit vs. miss at the type level. None represents a cache miss: the key isn’t there, so the handler must fall back to Postgres.
  4. It reflects that the cache is an optimization, not a dependency. .unwrap_or(None) treats a Redis read error as a miss, and binding the set_ex to _ 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.)
  5. 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.