karthik.dev
Back to blogBackend Engineering

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.