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:
| Feature | PostgreSQL | Redis |
|---|---|---|
| Storage | Disk-based (persistent) | Memory-based (fast) |
| Latency | ~1-5ms per query | ~0.1-0.5ms per query |
| Throughput | ~10K queries/sec | ~100K+ queries/sec |
| Data model | Relational tables | Key-value + data structures |
| Best for | Source of truth | Caching, 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 runningTest Redis is working:
docker exec -it url-shortener-redis redis-cli ping
# PONGUpdate 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=20Update 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/ioredisWe 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 shutdown —
closeRedisConnection()cleanly disconnects on server shutdown - Health check —
isRedisHealthy()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:
- Check Redis for the data
- If found (cache hit), return it
- If not found (cache miss), query PostgreSQL
- Store the result in Redis for next time
- 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 counterURL 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:
-
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.
-
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.
-
Application-level expiry check — even with TTL, we double-check
expiresAton 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
| Event | Action | Why |
|---|---|---|
| URL updated | Delete from cache | Next request fetches fresh data |
| URL deleted | Delete from cache | Prevent stale redirects |
| URL deactivated | Delete from cache | Stop serving inactive URLs |
| URL expired | Automatic (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:
- Simplicity — delete is one operation. Update requires reading the full record, serializing, and writing. More code, more bugs.
- 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
Recommended TTL Values
| Use Case | TTL | Reasoning |
|---|---|---|
| URL lookups (redirect) | 1 hour | URLs rarely change, high read frequency |
| URL stats/analytics | 5 minutes | Stats change on every click |
| Rate limit windows | 1 minute | Short windows for accurate limiting |
| User sessions | 24 hours | Balance 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:
| Algorithm | How it Works | Downside |
|---|---|---|
| Fixed window | Count 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 bucket | Tokens refill at a fixed rate; each request costs a token | Complex to implement correctly |
| Sliding window | Track each request timestamp; count requests in rolling window | Uses 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:
- Remove all entries older than
now - windowSize - Count remaining entries
- If count < maxRequests, add the new request and allow it
- 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:
- Global — 100 requests/minute per IP across all endpoints. Prevents general abuse.
- 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 Structure | Used For | Commands | Why This Structure |
|---|---|---|---|
String (GET/SET) | URL cache | GET, SETEX, DEL | Simple key-value; perfect for caching JSON objects |
Sorted Set (ZADD/ZCARD) | Rate limiting | ZADD, ZCARD, ZREMRANGEBYSCORE | Score = 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:abc123Sorted 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 60Health 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
| Metric | Without Redis | With Redis | Improvement |
|---|---|---|---|
| Avg redirect latency | 3-8ms | 0.2-0.8ms | 5-10x faster |
| P99 latency | 15-25ms | 1-2ms | 10-15x faster |
| DB queries/sec | 10,000 | ~100 (cache misses only) | 99% reduction |
| Max throughput | ~5K req/sec | ~50K req/sec | 10x 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.jsonNew additions:
src/cache/— cache key management and URL cache servicesrc/config/redis.ts— Redis client with connection managementsrc/middleware/rateLimiter.ts— sliding window rate limitersrc/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 WITHSCORESTesting 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"}')"
doneExpected 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/abc123What'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.