Back to blog

Build a URL Shortener: Caching with Redis

typescriptnodejsrediscachingbackend
Build a URL Shortener: Caching with Redis

Your redirect endpoint works. A user visits short.ly/abc123, the server queries PostgreSQL, finds the original URL, and sends a redirect. Done in ~5ms. Not bad.

Now imagine 10,000 redirects per second. Every single one hits the database. PostgreSQL handles it for a while, but you're paying a network round-trip and a disk read for data that almost never changes. The same short code resolves to the same URL every time — this is the textbook case for caching.

In this post, we'll add Redis as a caching layer for URL lookups and build a sliding window rate limiter to prevent abuse. By the end, your redirects will drop from ~5ms to under 1ms, and your API will be protected from traffic spikes.

Time commitment: 1–2 hours
Prerequisites: Phase 4: Redirect Engine & Click Analytics

What we'll build in this post:
✅ Redis added to Docker Compose alongside PostgreSQL
✅ Redis client with connection management and health checks
✅ Cache-aside pattern for URL lookups
✅ Cache invalidation on URL updates and deletes
✅ TTL-based expiration for cached entries
✅ Sliding window rate limiter using Redis sorted sets
✅ Rate limiting middleware for API endpoints
✅ Performance comparison: cached vs uncached redirects


Why Redis?

Before writing code, let's understand why Redis is the right tool here:

FeaturePostgreSQLRedis
StorageDisk-based (persistent)Memory-based (fast)
Latency~1-5ms per query~0.1-0.5ms per query
Throughput~10K queries/sec~100K+ queries/sec
Data modelRelational tablesKey-value + data structures
Best forSource of truthCaching, rate limiting, sessions

Redis isn't replacing PostgreSQL — it sits in front of it. PostgreSQL remains the source of truth; Redis is a fast read-through cache that prevents unnecessary database hits.


Adding Redis to Docker Compose

We already have PostgreSQL running in Docker Compose from the database post. Let's add Redis:

# docker-compose.yml
version: '3.8'
 
services:
  postgres:
    image: postgres:16-alpine
    container_name: url-shortener-db
    environment:
      POSTGRES_USER: urlshortener
      POSTGRES_PASSWORD: urlshortener
      POSTGRES_DB: urlshortener
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
 
  redis:
    image: redis:7-alpine
    container_name: url-shortener-redis
    ports:
      - '6379:6379'
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout: 5s
      retries: 3
 
volumes:
  postgres_data:
  redis_data:

Key configuration choices:

  • redis:7-alpine — latest stable Redis on a minimal image
  • --maxmemory 128mb — cap memory usage for development (adjust for production)
  • --maxmemory-policy allkeys-lru — when memory is full, evict least recently used keys. Perfect for a cache — stale entries get evicted automatically
  • healthcheck — Docker monitors Redis health and restarts if needed

Start the updated stack:

docker compose up -d
docker compose ps  # Verify both services are running

Test Redis is working:

docker exec -it url-shortener-redis redis-cli ping
# PONG

Update Environment Variables

Add Redis configuration to .env:

# .env
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development
 
# Database
DATABASE_URL=postgresql://urlshortener:urlshortener@localhost:5432/urlshortener
 
# Redis
REDIS_URL=redis://localhost:6379
REDIS_KEY_PREFIX=urlshort:
CACHE_TTL_SECONDS=3600
RATE_LIMIT_WINDOW_SECONDS=60
RATE_LIMIT_MAX_REQUESTS=20

Update the environment config:

// src/config/env.ts
import dotenv from 'dotenv';
dotenv.config();
 
export const env = {
  port: parseInt(process.env.PORT || '3000', 10),
  baseUrl: process.env.BASE_URL || 'http://localhost:3000',
  nodeEnv: process.env.NODE_ENV || 'development',
  databaseUrl: process.env.DATABASE_URL!,
 
  // Redis
  redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
  redisKeyPrefix: process.env.REDIS_KEY_PREFIX || 'urlshort:',
  cacheTtlSeconds: parseInt(process.env.CACHE_TTL_SECONDS || '3600', 10),
 
  // Rate limiting
  rateLimitWindowSeconds: parseInt(process.env.RATE_LIMIT_WINDOW_SECONDS || '60', 10),
  rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '20', 10),
};

Redis Client Setup

Install the Redis client:

npm install ioredis
npm install -D @types/ioredis

We use ioredis over the official redis package because it has better TypeScript support, built-in cluster support, automatic reconnection, and Lua scripting helpers.

Connection Management

// src/config/redis.ts
import Redis from 'ioredis';
import { env } from './env';
 
let redisClient: Redis | null = null;
 
export function getRedisClient(): Redis {
  if (!redisClient) {
    redisClient = new Redis(env.redisUrl, {
      maxRetriesPerRequest: 3,
      retryStrategy(times: number) {
        if (times > 10) {
          console.error('Redis: max retry attempts reached, giving up');
          return null; // Stop retrying
        }
        const delay = Math.min(times * 200, 2000);
        console.warn(`Redis: retrying connection in ${delay}ms (attempt ${times})`);
        return delay;
      },
      lazyConnect: false,
      enableReadyCheck: true,
    });
 
    redisClient.on('connect', () => {
      console.log('Redis: connected');
    });
 
    redisClient.on('ready', () => {
      console.log('Redis: ready to accept commands');
    });
 
    redisClient.on('error', (err) => {
      console.error('Redis: connection error', err.message);
    });
 
    redisClient.on('close', () => {
      console.warn('Redis: connection closed');
    });
  }
 
  return redisClient;
}
 
export async function closeRedisConnection(): Promise<void> {
  if (redisClient) {
    await redisClient.quit();
    redisClient = null;
    console.log('Redis: connection closed gracefully');
  }
}
 
export async function isRedisHealthy(): Promise<boolean> {
  try {
    const client = getRedisClient();
    const result = await client.ping();
    return result === 'PONG';
  } catch {
    return false;
  }
}

Why this design:

  • Singleton pattern — one connection shared across the application. Redis connections are multiplexed, so you don't need a pool like PostgreSQL
  • Retry strategy — exponential backoff up to 2 seconds, gives up after 10 attempts
  • Event listeners — log connection state changes for debugging
  • Graceful shutdowncloseRedisConnection() cleanly disconnects on server shutdown
  • Health checkisRedisHealthy() for readiness probes

Graceful Shutdown

Update your server entry point to close Redis on shutdown:

// src/index.ts (add to existing shutdown handler)
import { closeRedisConnection } from './config/redis';
 
async function gracefulShutdown() {
  console.log('Shutting down gracefully...');
  await closeRedisConnection();
  // ... close database, HTTP server, etc.
  process.exit(0);
}
 
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

Cache-Aside Pattern for URL Lookups

The cache-aside (or "lazy-loading") pattern is the most common caching strategy. The application manages the cache explicitly:

  1. Check Redis for the data
  2. If found (cache hit), return it
  3. If not found (cache miss), query PostgreSQL
  4. Store the result in Redis for next time
  5. Return the data

Cache Key Design

Good key design matters. Keys should be:

  • Namespaced — prefix prevents collisions with other apps sharing Redis
  • Descriptive — easy to debug with redis-cli KEYS *
  • Consistent — same structure everywhere
// src/cache/keys.ts
import { env } from '../config/env';
 
const prefix = env.redisKeyPrefix;
 
export const CacheKeys = {
  /** Cache key for a URL record by short code */
  url: (shortCode: string) => `${prefix}url:${shortCode}`,
 
  /** Cache key for URL stats */
  urlStats: (shortCode: string) => `${prefix}stats:${shortCode}`,
 
  /** Rate limiter key by IP address */
  rateLimit: (ip: string) => `${prefix}ratelimit:${ip}`,
 
  /** Rate limiter key by IP + endpoint */
  rateLimitEndpoint: (ip: string, endpoint: string) =>
    `${prefix}ratelimit:${ip}:${endpoint}`,
};

Example keys in Redis:

urlshort:url:abc123          → cached URL record
urlshort:stats:abc123        → cached click stats
urlshort:ratelimit:192.168.1.1  → rate limit counter

URL Cache Service

Now let's build the caching layer. This service wraps Redis operations and handles serialization/deserialization:

// src/cache/urlCache.ts
import { getRedisClient } from '../config/redis';
import { env } from '../config/env';
import { CacheKeys } from './keys';
 
export interface CachedUrl {
  id: string;
  shortCode: string;
  originalUrl: string;
  expiresAt: string | null;
}
 
export class UrlCacheService {
  private redis = getRedisClient();
  private ttl = env.cacheTtlSeconds;
 
  /**
   * Get a URL from cache by short code.
   * Returns null on cache miss or if the entry has expired.
   */
  async get(shortCode: string): Promise<CachedUrl | null> {
    try {
      const key = CacheKeys.url(shortCode);
      const cached = await this.redis.get(key);
 
      if (!cached) {
        return null; // Cache miss
      }
 
      const parsed: CachedUrl = JSON.parse(cached);
 
      // Check if the URL has expired (application-level expiry)
      if (parsed.expiresAt && new Date(parsed.expiresAt) < new Date()) {
        await this.delete(shortCode);
        return null;
      }
 
      return parsed;
    } catch (error) {
      // Cache errors should not break the application
      console.error('Cache get error:', error);
      return null;
    }
  }
 
  /**
   * Store a URL in cache with TTL.
   * If the URL has its own expiration, use the shorter of the two.
   */
  async set(url: CachedUrl): Promise<void> {
    try {
      const key = CacheKeys.url(url.shortCode);
      const value = JSON.stringify(url);
 
      let effectiveTtl = this.ttl;
 
      // If the URL expires sooner than cache TTL, use the URL's expiry
      if (url.expiresAt) {
        const urlTtl = Math.floor(
          (new Date(url.expiresAt).getTime() - Date.now()) / 1000
        );
        if (urlTtl > 0) {
          effectiveTtl = Math.min(effectiveTtl, urlTtl);
        } else {
          return; // URL already expired, don't cache
        }
      }
 
      await this.redis.setex(key, effectiveTtl, value);
    } catch (error) {
      console.error('Cache set error:', error);
      // Don't throw — caching is best-effort
    }
  }
 
  /**
   * Remove a URL from cache (used for invalidation).
   */
  async delete(shortCode: string): Promise<void> {
    try {
      const key = CacheKeys.url(shortCode);
      await this.redis.del(key);
    } catch (error) {
      console.error('Cache delete error:', error);
    }
  }
 
  /**
   * Remove multiple URLs from cache at once.
   */
  async deleteMany(shortCodes: string[]): Promise<void> {
    if (shortCodes.length === 0) return;
 
    try {
      const keys = shortCodes.map((code) => CacheKeys.url(code));
      await this.redis.del(...keys);
    } catch (error) {
      console.error('Cache deleteMany error:', error);
    }
  }
}

Three critical design decisions here:

  1. Errors never propagate — every method catches exceptions and logs them. If Redis goes down, the application still works (just slower, hitting PostgreSQL directly). This is the cache-aside golden rule: the cache is an optimization, not a dependency.

  2. Smart TTL calculation — if a short URL expires in 30 minutes but the cache TTL is 1 hour, we cache for 30 minutes. This prevents serving stale data for expired URLs.

  3. Application-level expiry check — even with TTL, we double-check expiresAt on read. This handles edge cases where the URL was updated after caching.


Integrating Cache into the Redirect Flow

Now let's wire the cache into the redirect service. The key change: check Redis before hitting PostgreSQL.

// src/services/redirectService.ts
import { UrlCacheService, CachedUrl } from '../cache/urlCache';
import { prisma } from '../config/database';
 
const urlCache = new UrlCacheService();
 
export async function resolveShortCode(shortCode: string) {
  // Step 1: Check cache
  const cached = await urlCache.get(shortCode);
 
  if (cached) {
    console.log(`Cache HIT for ${shortCode}`);
    return {
      id: cached.id,
      originalUrl: cached.originalUrl,
      shortCode: cached.shortCode,
      source: 'cache' as const,
    };
  }
 
  console.log(`Cache MISS for ${shortCode}`);
 
  // Step 2: Query database
  const url = await prisma.url.findUnique({
    where: { shortCode },
    select: {
      id: true,
      shortCode: true,
      originalUrl: true,
      expiresAt: true,
      isActive: true,
    },
  });
 
  if (!url || !url.isActive) {
    return null;
  }
 
  // Check expiration
  if (url.expiresAt && url.expiresAt < new Date()) {
    return null;
  }
 
  // Step 3: Populate cache for next request
  const cacheEntry: CachedUrl = {
    id: url.id,
    shortCode: url.shortCode,
    originalUrl: url.originalUrl,
    expiresAt: url.expiresAt?.toISOString() ?? null,
  };
 
  await urlCache.set(cacheEntry);
 
  return {
    id: url.id,
    originalUrl: url.originalUrl,
    shortCode: url.shortCode,
    source: 'database' as const,
  };
}

Updated Redirect Route

// src/routes/redirectRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { resolveShortCode } from '../services/redirectService';
import { recordClick } from '../services/analyticsService';
 
const router = Router();
 
router.get('/:code', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { code } = req.params;
    const result = await resolveShortCode(code);
 
    if (!result) {
      return res.status(404).json({ error: 'Short URL not found or expired' });
    }
 
    // Record click asynchronously — don't block the redirect
    recordClick(result.id, req).catch((err) =>
      console.error('Failed to record click:', err)
    );
 
    // Log cache performance (remove in production or use proper metrics)
    if (result.source === 'cache') {
      console.log(`Redirect ${code} served from cache`);
    }
 
    return res.redirect(302, result.originalUrl);
  } catch (error) {
    next(error);
  }
});
 
export default router;

Notice: recordClick() runs asynchronously with .catch() — we don't want analytics to slow down the redirect. The user gets their redirect immediately; the click gets recorded in the background.


Cache Invalidation

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

Cache invalidation is where most caching implementations go wrong. If you update a URL's destination but the old destination is still cached, users get redirected to the wrong place. Here's our strategy:

When to Invalidate

EventActionWhy
URL updatedDelete from cacheNext request fetches fresh data
URL deletedDelete from cachePrevent stale redirects
URL deactivatedDelete from cacheStop serving inactive URLs
URL expiredAutomatic (TTL)Redis evicts expired keys

Invalidation in the URL Service

// src/services/urlService.ts
import { UrlCacheService } from '../cache/urlCache';
 
const urlCache = new UrlCacheService();
 
export async function updateUrl(
  shortCode: string,
  updates: { originalUrl?: string; expiresAt?: Date; isActive?: boolean }
) {
  // Update in database (source of truth)
  const updated = await prisma.url.update({
    where: { shortCode },
    data: updates,
  });
 
  // Invalidate cache — next request will re-populate
  await urlCache.delete(shortCode);
 
  return updated;
}
 
export async function deleteUrl(shortCode: string) {
  await prisma.url.delete({
    where: { shortCode },
  });
 
  // Remove from cache
  await urlCache.delete(shortCode);
}
 
export async function deactivateUrl(shortCode: string) {
  await prisma.url.update({
    where: { shortCode },
    data: { isActive: false },
  });
 
  // Remove from cache — deactivated URLs should not resolve
  await urlCache.delete(shortCode);
}

The pattern is simple: write to database first, then invalidate cache. This order matters. If you invalidate the cache first and the database write fails, a new request might re-populate the cache with stale data before the write retries.

Why Delete, Not Update?

You might wonder: why not update the cache instead of deleting it? Two reasons:

  1. Simplicity — delete is one operation. Update requires reading the full record, serializing, and writing. More code, more bugs.
  2. Correctness — if the database write succeeds but the cache update fails, you have inconsistent data. With delete-on-write, the worst case is a single cache miss (which is fine).

This is called write-invalidate strategy, and it's the safest approach for most applications.


TTL Management

TTL (Time-To-Live) controls how long cached data stays in Redis. Choose too short and you get more cache misses (more database hits). Choose too long and you serve stale data longer.

TTL Strategy

Use CaseTTLReasoning
URL lookups (redirect)1 hourURLs rarely change, high read frequency
URL stats/analytics5 minutesStats change on every click
Rate limit windows1 minuteShort windows for accurate limiting
User sessions24 hoursBalance between UX and security

Dynamic TTL Based on Popularity

For a more sophisticated approach, you can adjust TTL based on how popular a URL is. Hot URLs stay cached longer:

// src/cache/urlCache.ts (enhanced set method)
 
async setWithDynamicTtl(url: CachedUrl, clickCount: number): Promise<void> {
  try {
    const key = CacheKeys.url(url.shortCode);
    const value = JSON.stringify(url);
 
    // More clicks = longer TTL (up to 24 hours)
    let ttl: number;
    if (clickCount > 10000) {
      ttl = 86400;  // 24 hours — viral URL
    } else if (clickCount > 1000) {
      ttl = 14400;  // 4 hours — popular URL
    } else if (clickCount > 100) {
      ttl = 3600;   // 1 hour — moderate traffic
    } else {
      ttl = 900;    // 15 minutes — low traffic
    }
 
    // Still respect URL-level expiration
    if (url.expiresAt) {
      const urlTtl = Math.floor(
        (new Date(url.expiresAt).getTime() - Date.now()) / 1000
      );
      if (urlTtl > 0) {
        ttl = Math.min(ttl, urlTtl);
      } else {
        return;
      }
    }
 
    await this.redis.setex(key, ttl, value);
  } catch (error) {
    console.error('Cache set error:', error);
  }
}

This is optional — the simple fixed TTL works fine for most cases. Only add dynamic TTL if you have evidence of highly skewed traffic patterns (a few URLs getting 90% of requests).


Sliding Window Rate Limiter

Rate limiting prevents abuse — one client shouldn't be able to send 1,000 requests per second and overwhelm your server. We'll implement a sliding window algorithm using Redis sorted sets.

Why Sliding Window?

There are three common approaches:

AlgorithmHow it WorksDownside
Fixed windowCount requests in fixed time slots (e.g., minute 0:00-1:00)Burst at window boundaries (200 req in last second + 200 in first second = 400 in 2 seconds)
Token bucketTokens refill at a fixed rate; each request costs a tokenComplex to implement correctly
Sliding windowTrack each request timestamp; count requests in rolling windowUses slightly more memory (stores timestamps)

Sliding window is the best balance of accuracy and simplicity. No boundary bursts, easy to understand, and Redis sorted sets make it efficient.

How It Works

Each request timestamp is stored in a Redis sorted set. The "score" is the timestamp. To check the rate:

  1. Remove all entries older than now - windowSize
  2. Count remaining entries
  3. If count < maxRequests, add the new request and allow it
  4. If count >= maxRequests, reject with 429

Implementation

// src/middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express';
import { getRedisClient } from '../config/redis';
import { CacheKeys } from '../cache/keys';
import { env } from '../config/env';
 
interface RateLimitConfig {
  windowSeconds: number;
  maxRequests: number;
  keyGenerator?: (req: Request) => string;
  message?: string;
}
 
const defaultConfig: RateLimitConfig = {
  windowSeconds: env.rateLimitWindowSeconds,
  maxRequests: env.rateLimitMaxRequests,
  message: 'Too many requests. Please try again later.',
};
 
export function rateLimiter(config: Partial<RateLimitConfig> = {}) {
  const {
    windowSeconds,
    maxRequests,
    keyGenerator,
    message,
  } = { ...defaultConfig, ...config };
 
  return async (req: Request, res: Response, next: NextFunction) => {
    const redis = getRedisClient();
 
    // Generate a unique key for this client
    const clientId = keyGenerator
      ? keyGenerator(req)
      : getClientIp(req);
 
    const key = CacheKeys.rateLimit(clientId);
    const now = Date.now();
    const windowStart = now - windowSeconds * 1000;
 
    try {
      // Use a Redis pipeline for atomicity and performance
      const pipeline = redis.pipeline();
 
      // Step 1: Remove expired entries (older than window start)
      pipeline.zremrangebyscore(key, 0, windowStart);
 
      // Step 2: Count current entries in the window
      pipeline.zcard(key);
 
      // Step 3: Add the current request
      pipeline.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
      // Step 4: Set expiry on the key (cleanup)
      pipeline.expire(key, windowSeconds);
 
      const results = await pipeline.exec();
 
      if (!results) {
        // Pipeline failed — allow the request (fail open)
        return next();
      }
 
      // Result at index 1 is the ZCARD result (count before adding)
      const currentCount = results[1][1] as number;
 
      if (currentCount >= maxRequests) {
        // Remove the request we just added (it shouldn't count)
        await redis.zremrangebyscore(key, now, now);
 
        // Calculate retry-after
        const oldestEntry = await redis.zrange(key, 0, 0, 'WITHSCORES');
        const retryAfter = oldestEntry.length >= 2
          ? Math.ceil((parseInt(oldestEntry[1]) + windowSeconds * 1000 - now) / 1000)
          : windowSeconds;
 
        res.set({
          'X-RateLimit-Limit': maxRequests.toString(),
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': Math.ceil((now + retryAfter * 1000) / 1000).toString(),
          'Retry-After': retryAfter.toString(),
        });
 
        return res.status(429).json({
          error: message,
          retryAfter,
        });
      }
 
      // Set rate limit headers on successful requests
      res.set({
        'X-RateLimit-Limit': maxRequests.toString(),
        'X-RateLimit-Remaining': (maxRequests - currentCount - 1).toString(),
        'X-RateLimit-Reset': Math.ceil((now + windowSeconds * 1000) / 1000).toString(),
      });
 
      next();
    } catch (error) {
      console.error('Rate limiter error:', error);
      // Fail open — if Redis is down, allow the request
      next();
    }
  };
}
 
function getClientIp(req: Request): string {
  const forwarded = req.headers['x-forwarded-for'];
  if (typeof forwarded === 'string') {
    return forwarded.split(',')[0].trim();
  }
  return req.ip || req.socket.remoteAddress || 'unknown';
}

Let's break down the important parts:

Pipeline — we batch four Redis commands into a single network round-trip. Without a pipeline, this would be four separate round-trips.

Math.random() in the member — sorted set members must be unique. If two requests arrive at the exact same millisecond, ${now}:${Math.random()} ensures they're stored as separate entries.

Fail open — if Redis is down, we allow the request. The alternative (fail closed) would block all traffic when Redis has issues. For most applications, fail open is the right choice.

Rate limit headers — standard X-RateLimit-* headers let clients know their quota and when it resets. The Retry-After header tells clients exactly how long to wait.

Applying Rate Limiting to Routes

// src/app.ts
import express from 'express';
import { rateLimiter } from './middleware/rateLimiter';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
 
const app = express();
app.use(express.json());
 
// Global rate limit: 100 requests per minute per IP
app.use(rateLimiter({
  windowSeconds: 60,
  maxRequests: 100,
}));
 
// Stricter rate limit for URL creation
app.use('/api/shorten', rateLimiter({
  windowSeconds: 60,
  maxRequests: 10,
  keyGenerator: (req) => `create:${getClientIp(req)}`,
  message: 'URL creation rate limit exceeded. Max 10 URLs per minute.',
}));
 
// API routes
app.use('/api', urlRoutes);
 
// Redirect route (less strict — these should be fast)
app.use('/', redirectRoutes);
 
export default app;

Two tiers of rate limiting:

  1. Global — 100 requests/minute per IP across all endpoints. Prevents general abuse.
  2. Endpoint-specific — 10 URL creations/minute. Creating URLs is more expensive than redirects, so it gets a tighter limit.

Redirects intentionally get a generous limit — the whole point of a URL shortener is handling lots of redirects.


Redis Data Structures Reference

Here's a summary of every Redis data structure we use and why:

Data StructureUsed ForCommandsWhy This Structure
String (GET/SET)URL cacheGET, SETEX, DELSimple key-value; perfect for caching JSON objects
Sorted Set (ZADD/ZCARD)Rate limitingZADD, ZCARD, ZREMRANGEBYSCOREScore = timestamp; efficient range queries for sliding window

String Operations (URL Cache)

# Store a URL with 1-hour TTL
SET urlshort:url:abc123 '{"id":"1","shortCode":"abc123","originalUrl":"https://example.com"}' EX 3600
 
# Retrieve
GET urlshort:url:abc123
 
# Delete (invalidation)
DEL urlshort:url:abc123
 
# Check TTL remaining
TTL urlshort:url:abc123

Sorted Set Operations (Rate Limiting)

# Add request timestamp (score = timestamp, member = unique ID)
ZADD urlshort:ratelimit:192.168.1.1 1711036800000 "1711036800000:0.42"
 
# Remove expired entries (older than 60 seconds ago)
ZREMRANGEBYSCORE urlshort:ratelimit:192.168.1.1 0 1711036740000
 
# Count requests in current window
ZCARD urlshort:ratelimit:192.168.1.1
 
# Set key expiry for cleanup
EXPIRE urlshort:ratelimit:192.168.1.1 60

Health Check Endpoint

Add a health check that reports Redis status alongside PostgreSQL:

// src/routes/healthRoutes.ts
import { Router, Request, Response } from 'express';
import { isRedisHealthy } from '../config/redis';
import { prisma } from '../config/database';
 
const router = Router();
 
router.get('/health', async (_req: Request, res: Response) => {
  const [redisOk, dbOk] = await Promise.all([
    isRedisHealthy(),
    prisma.$queryRaw`SELECT 1`.then(() => true).catch(() => false),
  ]);
 
  const status = redisOk && dbOk ? 'healthy' : 'degraded';
 
  res.status(redisOk && dbOk ? 200 : 503).json({
    status,
    services: {
      redis: redisOk ? 'connected' : 'disconnected',
      database: dbOk ? 'connected' : 'disconnected',
    },
    timestamp: new Date().toISOString(),
  });
});
 
export default router;

Note the response is degraded, not down. The application still works without Redis — it just falls back to database-only lookups. This is the benefit of the cache-aside pattern: Redis is an optimization, not a hard dependency.


Performance Comparison

Let's measure the impact. Here's a simple benchmark you can run locally:

// scripts/benchmark-cache.ts
import { resolveShortCode } from '../src/services/redirectService';
import { getRedisClient } from '../src/config/redis';
 
async function benchmark(shortCode: string, iterations: number) {
  const redis = getRedisClient();
 
  // Warm up — first request populates cache
  await resolveShortCode(shortCode);
 
  // Benchmark with cache
  const cacheStart = performance.now();
  for (let i = 0; i < iterations; i++) {
    await resolveShortCode(shortCode);
  }
  const cacheTime = performance.now() - cacheStart;
 
  // Clear cache
  await redis.flushdb();
 
  // Benchmark without cache (every request hits DB)
  const dbStart = performance.now();
  for (let i = 0; i < iterations; i++) {
    await resolveShortCode(shortCode);
    await redis.flushdb(); // Clear after each to force DB hit
  }
  const dbTime = performance.now() - dbStart;
 
  console.log(`\n--- Benchmark Results (${iterations} iterations) ---`);
  console.log(`With cache:    ${(cacheTime / iterations).toFixed(2)}ms avg`);
  console.log(`Without cache: ${(dbTime / iterations).toFixed(2)}ms avg`);
  console.log(`Speedup:       ${(dbTime / cacheTime).toFixed(1)}x faster`);
  console.log(`Cache hit rate: ~${((1 - 1/iterations) * 100).toFixed(0)}%`);
}
 
benchmark('abc123', 1000);

Expected Results

MetricWithout RedisWith RedisImprovement
Avg redirect latency3-8ms0.2-0.8ms5-10x faster
P99 latency15-25ms1-2ms10-15x faster
DB queries/sec10,000~100 (cache misses only)99% reduction
Max throughput~5K req/sec~50K req/sec10x more

The biggest win isn't individual request speed — it's database load reduction. With a 95% cache hit rate, your PostgreSQL handles 20x fewer queries. This means you can scale much further before needing database replicas or sharding.


Common Mistakes and How to Avoid Them

1. Cache Stampede

Problem: When a popular cached key expires, hundreds of simultaneous requests all see a cache miss and all query the database at the same time. The database gets hammered with identical queries.

Solution: Locking (mutex) pattern

// src/cache/urlCache.ts — add to UrlCacheService
 
async getWithLock(shortCode: string): Promise<CachedUrl | null> {
  // Try cache first
  const cached = await this.get(shortCode);
  if (cached) return cached;
 
  // Try to acquire a lock
  const lockKey = `${CacheKeys.url(shortCode)}:lock`;
  const acquired = await this.redis.set(lockKey, '1', 'EX', 5, 'NX');
 
  if (acquired) {
    // We got the lock — fetch from DB and populate cache
    // (caller handles the DB query and calls this.set())
    return null; // Signal: "you need to fetch from DB"
  }
 
  // Someone else has the lock — wait briefly and retry from cache
  await new Promise((resolve) => setTimeout(resolve, 50));
  return this.get(shortCode);
}

For most URL shorteners, the simple approach without locking is fine. Cache stampede becomes a real problem only at very high traffic (100K+ requests/second on the same key).

2. Stale Data After Updates

Problem: You update a URL's destination, but users still get redirected to the old URL.

Solution: Always invalidate on write (which we already do):

// CORRECT: invalidate after update
await prisma.url.update({ where: { shortCode }, data: { originalUrl: newUrl } });
await urlCache.delete(shortCode);
 
// WRONG: update cache directly (risks inconsistency)
await urlCache.set({ ...existing, originalUrl: newUrl });
await prisma.url.update({ where: { shortCode }, data: { originalUrl: newUrl } });

3. Not Setting Key Expiry on Rate Limit Keys

Problem: If you forget to set EXPIRE on rate limit sorted set keys, they accumulate forever. After months of operation, Redis memory fills up with stale rate limit data.

Solution: Always set EXPIRE on rate limit keys:

// CORRECT
pipeline.expire(key, windowSeconds);
 
// WRONG — key lives forever
// (no EXPIRE call)

4. Caching Errors

Problem: A database query fails, and you cache the error response. Now every subsequent request gets the cached error.

Solution: Only cache successful lookups:

// CORRECT
const url = await prisma.url.findUnique({ where: { shortCode } });
if (url) {
  await urlCache.set(toCachedUrl(url)); // Only cache if found
}
 
// WRONG
try {
  const url = await prisma.url.findUnique({ where: { shortCode } });
  await urlCache.set(url); // Caches null on not-found!
} catch (error) {
  await urlCache.set(null); // Caches errors!
}

5. Using Redis as Primary Storage

Problem: Treating Redis as the source of truth. Redis data can be lost on restart (unless persistence is configured), and eviction policies can delete keys unexpectedly.

Solution: PostgreSQL is always the source of truth. Redis is a cache — data can be rebuilt from the database at any time.


Updated Project Structure

After this post, the project structure grows:

url-shortener/
├── src/
│   ├── cache/
│   │   ├── keys.ts              # Cache key generators
│   │   └── urlCache.ts          # URL cache service
│   ├── config/
│   │   ├── env.ts               # Environment variables
│   │   ├── database.ts          # Prisma client
│   │   └── redis.ts             # Redis client + connection mgmt
│   ├── middleware/
│   │   ├── errorHandler.ts      # Error handling
│   │   ├── rateLimiter.ts       # Sliding window rate limiter
│   │   └── validateRequest.ts   # Request validation
│   ├── routes/
│   │   ├── healthRoutes.ts      # Health check endpoint
│   │   ├── redirectRoutes.ts    # Redirect with cache
│   │   └── urlRoutes.ts         # URL CRUD endpoints
│   ├── services/
│   │   ├── analyticsService.ts  # Click tracking
│   │   ├── redirectService.ts   # Resolve short codes (cache-aside)
│   │   └── urlService.ts        # URL CRUD with invalidation
│   ├── types/
│   │   └── url.ts
│   ├── app.ts
│   └── index.ts
├── docker-compose.yml           # PostgreSQL + Redis
├── .env
└── package.json

New additions:

  • src/cache/ — cache key management and URL cache service
  • src/config/redis.ts — Redis client with connection management
  • src/middleware/rateLimiter.ts — sliding window rate limiter
  • src/routes/healthRoutes.ts — health check with Redis status
  • Updated docker-compose.yml — Redis service added

Testing the Cache

Manual Testing with redis-cli

# Connect to Redis
docker exec -it url-shortener-redis redis-cli
 
# Check all keys
KEYS urlshort:*
 
# Inspect a cached URL
GET urlshort:url:abc123
 
# Check TTL remaining
TTL urlshort:url:abc123
 
# Monitor all Redis commands in real-time (useful for debugging)
MONITOR
 
# Check memory usage
INFO memory
 
# Check rate limit entries for an IP
ZRANGE urlshort:ratelimit:127.0.0.1 0 -1 WITHSCORES

Testing Rate Limiting

# Send 25 requests quickly — should get 429 after 20
for i in $(seq 1 25); do
  echo "Request $i: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/shorten -X POST -H 'Content-Type: application/json' -d '{"url":"https://example.com"}')"
done

Expected output:

Request 1: 201
Request 2: 201
...
Request 20: 201
Request 21: 429
Request 22: 429
...

Testing Cache Performance

# First request — cache miss (slower)
time curl -o /dev/null -s http://localhost:3000/abc123
 
# Second request — cache hit (faster)
time curl -o /dev/null -s http://localhost:3000/abc123

What's Next

With Redis in place, your URL shortener can handle significantly more traffic. Redirects are sub-millisecond, the database is protected from read storms, and abusive clients get rate-limited automatically.

In the next post, we'll add user authentication — registration, login with JWT tokens, and user-scoped URL management. Users will be able to see their own URLs, edit them, and view analytics for their links.

Key topics in Phase 6:

  • JWT authentication with refresh tokens
  • User registration and login endpoints
  • Password hashing with bcrypt
  • Auth middleware for protected routes
  • User-scoped URL ownership

Series: Build a URL Shortener
Previous: Phase 4: Redirect Engine & Click Analytics
Next: Phase 6: User Authentication & URL Management

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.