How we handle 50,000 redirects per second

The redirect path is the only part of Shortifi that every end-user touches. Here’s how we engineered it to stay under 5 ms at peak load — and what we learned doing it.

Shortifi.me Dec 14, 2025 3 min read

The constraint that shapes everything

Every short link we serve has one job: resolve as fast as possible and get out of the way. A user clicks a link in an email, a tweet, or an ad. They don’t know Shortifi exists. All they know is whether the page opened quickly or whether they stared at a spinner for half a second.

That half-second matters. Every 100 ms of redirect latency translates directly into measurable drop-off. Our target from day one: p99 under 5 ms for the redirect path, measured at the application boundary (not including TLS handshake or last-mile network).

Redirect infrastructure diagram showing Redis cache layer in front of PostgreSQL

Why PostgreSQL alone isn’t enough

Our primary data store is PostgreSQL 16. Links, workspaces, click events — it all lives there. PostgreSQL is fast, but it isn’t designed for 50,000 concurrent single-row lookups per second on a single node without serious connection pool pressure.

A naive implementation would look like this:

async def redirect(shortcode: str) -> str:
    link = await db.execute(
        select(Link).where(Link.short_code == shortcode)
    )
    return link.target_url

That works fine at 100 req/s. At 50,000 it saturates the connection pool and your p99 starts climbing toward 50 ms, then 200 ms, then you’re paging at 2 AM.

The Redis cache layer

The redirect path uses Redis 7 as an L1 cache. The lookup key is redir:{shortcode} and the value is the serialized target URL (with optional expiry metadata). A cache hit completes in under 1 ms on local network.

The cache population strategy is write-through: when a link is created or updated, we write to both PostgreSQL and Redis atomically (using a pipeline). On a cache miss — which should be rare except for brand-new or long-tail links — we fall back to PostgreSQL, write the result to Redis with a TTL, and return.

async def get_target_url(shortcode: str) -> str | None:
    cached = await redis.get(f"redir:{shortcode}")
    if cached:
        return cached.decode()
    row = await repo.find_by_shortcode(shortcode)
    if row is None:
        return None
    await redis.setex(f"redir:{shortcode}", 3600, row.target_url)
    return row.target_url

Handling cache invalidation

Cache invalidation is, famously, one of the two hard problems in computer science. For Shortifi’s redirect cache the rules are simple:

  1. Link updated → evict redir:{shortcode} and re-populate with the new target URL.
  2. Link deleted → evict redir:{shortcode} and set a sentinel value (__deleted__) with a short TTL to prevent thundering herd against PostgreSQL.
  3. Link expired (time-bounded links) → a background worker evicts the key at expiry time; the app also checks expiry on cache hit.

Thundering herd protection

If a popular link’s cache entry expires and 5,000 requests arrive simultaneously, they all miss and flood PostgreSQL. We prevent this with a probabilistic early expiration approach: we store the expiry time alongside the value and begin probabilistically refreshing entries slightly before they expire, using a single async refresh task per key.

Connection pooling and FastAPI

FastAPI runs with uvicorn workers and asyncpg as the PostgreSQL driver. We configure asyncpg connection pools per worker, sized to the Postgres max_connections limit divided by worker count, with a small headroom buffer. Redis connections use redis-py async with a shared pool per process.

The redirect endpoint itself is deliberately minimal — no middleware, no authentication, no session lookup. It runs on the same FastAPI app but with its own router that bypasses the full middleware stack for auth and logging. Structured logging for clicks is fire-and-forget via Redis Streams (covered in the next post).

What the numbers look like

  • Cache hit rate: 98.7% under normal conditions
  • p50 latency (cache hit): 0.8 ms
  • p99 latency (cache hit): 3.1 ms
  • p99 latency (cache miss → PG fallback): 11 ms
  • Postgres load at 50k req/s: ~620 connections, ~1,200 cache-miss queries/s

At this scale, Redis absorbs almost all the read load. PostgreSQL only sees misses, writes, and analytics queries — entirely different traffic patterns that don’t compete.

What we’d change at 500k req/s

At an order of magnitude higher, a single Redis node becomes the bottleneck. The upgrade path is Redis Cluster with consistent hashing on the shortcode key. We’ve designed the cache key scheme to be shard-friendly from day one — no MGET across keys, no Lua scripts that span multiple slots.

Run the work behind the click.

Start free. No credit card. One workspace included.

Start free