Build a URL Shortener from Scratch — Part 4: Redis Caching for Fast Redirects

Why Caching Matters

Without caching, every redirect hits PostgreSQL. With Redis, we can serve redirects in under 1ms by storing the short_code → original_url mapping in memory. The cache-aside pattern works perfectly here:

  1. Read: Check Redis → If miss, query PostgreSQL → Store in Redis → Return
  2. Write: Store in PostgreSQL → Store in Redis

Step 1: Redis Client Setup

# app/core/cache.py
import redis
from app.config import settings

redis_client = redis.Redis.from_url(
    settings.redis_url,
    decode_responses=True,
    socket_connect_timeout=5,
    socket_timeout=2
)

CACHE_TTL = 60 * 60 * 24 * 7  # 1 week

def cache_url(short_code: str, original_url: str):
    redis_client.setex(f"url:{short_code}", CACHE_TTL, original_url)

def get_cached_url(short_code: str) -> str | None:
    return redis_client.get(f"url:{short_code}")

def invalidate_cache(short_code: str):
    redis_client.delete(f"url:{short_code}")

Step 2: Updated Redirect Endpoint

# app/api/routes.py (updated redirect)
from app.core.cache import get_cached_url, cache_url

@router.get("/{code}")
def redirect_to_url(code: str, db: Session = Depends(get_db)):
    # 1. Check Redis cache first
    cached = get_cached_url(code)
    if cached:
        # Increment click count asynchronously
        increment_click_count.delay(code)  # Celery/background task
        return RedirectResponse(url=cached, status_code=307)

    # 2. Cache miss — query PostgreSQL
    service = ShortenerService(db)
    url = service.get_by_code(code)
    if not url:
        raise HTTPException(status_code=404, detail="Short URL not found")

    # 3. Check expiration
    from datetime import datetime, timezone
    if url.expires_at and url.expires_at < datetime.now(timezone.utc):
        raise HTTPException(status_code=410, detail="Short URL has expired")

    # 4. Cache for next time
    cache_url(code, url.original_url)

    # 5. Increment click counter
    url.click_count += 1
    db.commit()

    return RedirectResponse(url=url.original_url, status_code=307)

Step 3: Cache on URL Creation

# In ShortenerService.create() — after commit
cache_url(url.short_code, url.original_url)

This ensures the cache is warm from the moment a URL is created.

Step 4: Background Click Tracking

For maximum redirect speed, move analytics to a background task:

# app/services/analytics.py
import redis as redis_lib
from app.core.cache import redis_client

def increment_click_count_background(short_code: str):
    """Increment click count in Redis, batch-write to PostgreSQL later."""
    redis_client.hincrby("clicks", short_code, 1)

def flush_clicks_to_db(db):
    """Periodic task: flush Redis click counts to PostgreSQL."""
    clicks = redis_client.hgetall("clicks")
    for short_code, count in clicks.items():
        url = db.query(URL).filter(URL.short_code == short_code).first()
        if url:
            url.click_count += int(count)
    db.commit()
    redis_client.delete("clicks")

Run flush_clicks_to_db as a cron job every minute or via Celery beat.

Step 5: Cache Invalidation

When updating or deleting a URL:

def update_url(short_code: str, new_url: str, db: Session):
    url = db.query(URL).filter(URL.short_code == short_code).first()
    url.original_url = new_url
    db.commit()
    invalidate_cache(short_code)  # Remove stale cache entry
    cache_url(short_code, new_url)  # Repopulate with new value

Performance Benchmarks

OperationWithout RedisWith Redis
Redirect15-30ms0.5-1ms
Cache hit ratio0%95%+

With a 95% cache hit rate, only 5% of requests touch PostgreSQL.

Verification

# Create a URL
curl -X POST http://localhost:8000/shorten \
  -d '{"url": "https://example.com"}'

# First request (cache miss, PostgreSQL)
curl -w "\nTime: %{time_total}s\n" -o /dev/null -s http://localhost:8000/q0Z
# Time: 0.025s

# Second request (cache hit, Redis)
curl -w "\nTime: %{time_total}s\n" -o /dev/null -s http://localhost:8000/q0Z
# Time: 0.003s — 8x faster!

# Verify cache exists in Redis
redis-cli GET "url:q0Z"
# "https://example.com"

Summary

  • Cache-aside pattern: check Redis → fallback to PostgreSQL → populate Redis
  • Write-through: cache on creation, invalidate on update
  • TTL of 1 week prevents stale cache from growing unboundedly
  • Background click tracking keeps redirects fast by deferring DB writes
  • 95%+ cache hit rate with Redis reduces PostgreSQL load dramatically

← Part 3 | Part 5 →


Advertisement