URL Shortener with Analytics: Sub-10ms Redirects and Async Click Processing
2026-05-15 · 5 min read
The Core Engineering Challenge
A URL shortener seems simple: map a short code to a long URL, redirect requests. But production URL shorteners have a fundamental tension:
Redirects must be fast (sub-10ms — users notice latency before the redirect)
Analytics must be complete (every click captured, with metadata)
Doing both synchronously is impossible at scale: a synchronous database write on every redirect would add 20-50ms of latency. You must decouple them.
Architecture: Decoupled Redirect and Analytics
GET /abc123
↓ (1ms)
Redis Cache → long URL found → return 302 immediately
↓ async (non-blocking)
Click Event Queue
↓ background worker
PostgreSQL Analytics Tables
The redirect path reads from Redis and returns in under 10ms. The analytics write happens asynchronously, never blocking the redirect.
The Redirect Handler
// src/routes/redirect.ts
export async function handleRedirect(
shortCode: string,
req: Request,
): Promise<RedirectResponse> {
const startTime = performance.now();
// 1. Try Redis cache (sub-1ms on hit)
const cached = await redis.get(`url:${shortCode}`);
if (cached) {
// Emit click event asynchronously — don't await!
emitClickEvent({
shortCode,
longUrl: cached,
timestamp: new Date(),
ip: req.headers["x-forwarded-for"] as string,
userAgent: req.headers["user-agent"],
referer: req.headers["referer"],
});
const latency = performance.now() - startTime;
console.log(`Cache hit: ${shortCode} → ${latency.toFixed(2)}ms`);
return { url: cached, cached: true };
}
// 2. Cache miss: query PostgreSQL
const record = await db.shortUrl.findUnique({
where: { shortCode, active: true },
});
if (!record) throw new NotFoundError(shortCode);
// Populate cache for next request
await redis.setex(`url:${shortCode}`, 86400, record.longUrl);
emitClickEvent({
shortCode,
longUrl: record.longUrl,
timestamp: new Date(),
ip: req.headers["x-forwarded-for"] as string,
userAgent: req.headers["user-agent"],
referer: req.headers["referer"],
});
return { url: record.longUrl, cached: false };
}
The emitClickEvent call is explicitly not awaited. It pushes an event to an in-process queue and returns immediately — zero impact on redirect latency.
Click Event Processing
// src/analytics/event-processor.ts
const clickQueue: ClickEvent[] = [];
let processorRunning = false;
export function emitClickEvent(event: ClickEvent): void {
clickQueue.push(event);
if (!processorRunning) startProcessor();
}
async function startProcessor(): Promise<void> {
processorRunning = true;
while (clickQueue.length > 0) {
// Batch process for efficiency
const batch = clickQueue.splice(0, 100);
try {
await processBatch(batch);
} catch (error) {
// On failure, push back to queue for retry
clickQueue.unshift(...batch);
await sleep(1000); // backoff before retry
}
}
processorRunning = false;
}
async function processBatch(events: ClickEvent[]): Promise<void> {
const enriched = await Promise.all(events.map(enrichWithGeoIP));
await db.click.createMany({
data: enriched.map(e => ({
shortCode: e.shortCode,
timestamp: e.timestamp,
country: e.geo?.country,
city: e.geo?.city,
device: parseDevice(e.userAgent),
browser: parseBrowser(e.userAgent),
refererDomain: extractDomain(e.referer),
})),
});
}
Short Code Generation
Collision-free short code generation under concurrent creation:
// src/services/shortener.ts
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const CODE_LENGTH = 6;
function generateCode(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(CODE_LENGTH)))
.map(byte => ALPHABET[byte % ALPHABET.length])
.join("");
}
export async function createShortUrl(longUrl: string, userId?: string): Promise<ShortUrl> {
// Retry on collision (extremely rare with 62^6 = 56B possibilities)
for (let attempt = 0; attempt < 5; attempt++) {
const shortCode = generateCode();
try {
const record = await db.shortUrl.create({
data: { shortCode, longUrl, userId, createdAt: new Date() },
});
return record;
} catch (error) {
if (isUniqueConstraintError(error)) continue; // retry
throw error;
}
}
throw new Error("Failed to generate unique short code after 5 attempts");
}
Using a unique database constraint as the collision guard is simpler and more reliable than generating a code and checking existence first (which has a TOCTOU race condition under concurrency).
Analytics Dashboard
The dashboard API aggregates click data efficiently using PostgreSQL window functions:
-- Clicks by day (last 30 days)
SELECT
DATE_TRUNC('day', timestamp) as day,
COUNT(*) as clicks,
COUNT(DISTINCT ip_hash) as unique_visitors
FROM clicks
WHERE short_code = $1 AND timestamp > NOW() - INTERVAL '30 days'
GROUP BY day
ORDER BY day;
-- Top referer domains
SELECT
referer_domain,
COUNT(*) as clicks
FROM clicks
WHERE short_code = $1
GROUP BY referer_domain
ORDER BY clicks DESC
LIMIT 10;
Performance Results
| Scenario | Latency |
|---|---|
| Cache hit | <5ms |
| Cache miss (DB) | 20-35ms |
| Analytics write | Async (non-blocking) |
At 10,000 clicks/minute, the analytics pipeline processes events in batches of 100, completing within 2-3 seconds of receipt — never blocking a redirect.
Docker Deployment
# docker-compose.yml
services:
app:
build: .
ports: ["3000:3000"]
environment:
- REDIS_URL=redis://redis:6379
- DATABASE_URL=postgresql://postgres:password@postgres:5432/urlshortener
depends_on: [redis, postgres]
redis:
image: redis:7-alpine
volumes: [redis-data:/data]
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: urlshortener
volumes: [postgres-data:/var/lib/postgresql/data]
The decoupled architecture means Redis and PostgreSQL can be scaled independently. High redirect traffic → scale Redis. High analytics write volume → scale the background processor workers.