← Back to DevBytes

Designing a URL Shortener for 1 Million Users

Designing a URL Shortener for 1 Million Users

What Is a URL Shortener?

A URL shortener is a web service that transforms long, unwieldy URLs into compact, easily shareable links. Behind the scenes, it stores a mapping between the original URL and a generated short code. When a user clicks the shortened link, the service looks up the original URL and redirects them seamlessly. Think of services like Bit.ly or TinyURL — they take a 200-character tracking URL and condense it into something like https://short.ly/x7K9pQ.

Why Designing for Scale Matters

When you're building for one million users, every architectural decision ripples across performance, cost, and reliability. A million users might generate:

Without proper design, you'll hit bottlenecks in database lookups, exhaust your primary keys, or suffer from hash collisions. A system built for 1,000 users will crumble under this load. This tutorial walks through building a production-ready URL shortener that handles massive scale while remaining fast, reliable, and cost-effective.

Core System Architecture

The high-level architecture consists of these components:

Step 1: Choosing the Short Code Generation Strategy

This is the heart of your system. You need unique, short, and URL-safe codes. There are three main approaches:

Approach A: Hash-Based (MD5 / SHA256 + Truncation)

Hash the URL, then take the first N characters. Simple but prone to collisions when truncated aggressively. For 1 million users generating millions of URLs, collision probability rises quickly.


// Node.js example — hash-based approach (NOT recommended for scale)
const crypto = require('crypto');

function generateShortCode(url) {
  const hash = crypto.createHash('sha256').update(url).digest('hex');
  // Take first 8 chars — collision risk grows with volume
  return hash.substring(0, 8);
}
// Problem: 8 hex chars = 16^8 ≈ 4.29 billion combinations
// But with millions of URLs, birthday paradox makes collisions likely

Approach B: Distributed Counter with Base62 Encoding

Maintain a distributed counter (e.g., using ZooKeeper or a dedicated counter service). Each server instance claims a range of IDs, then converts them to Base62. This guarantees uniqueness and gives you short, readable codes.


// Base62 encoding — converts integer IDs to short alphanumeric strings
const BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

function encodeBase62(num) {
  if (num === 0) return BASE62_ALPHABET[0];
  let result = '';
  while (num > 0) {
    result = BASE62_ALPHABET[num % 62] + result;
    num = Math.floor(num / 62);
  }
  return result;
}

function decodeBase62(str) {
  let num = 0;
  for (let i = 0; i < str.length; i++) {
    num = num * 62 + BASE62_ALPHABET.indexOf(str[i]);
  }
  return num;
}

// Examples:
// encodeBase62(1) → "1"
// encodeBase62(1000000) → "4c92"
// encodeBase62(1000000000) → "15FTgN"

For a counter-based system, each application instance claims a block of IDs:


// Distributed counter using Redis INCR
const redis = require('ioredis');
const client = new redis();

class IDGenerator {
  constructor(serviceId) {
    this.serviceId = serviceId;
    this.currentMax = 0;
    this.currentOffset = 0;
  }

  async claimIDBlock(blockSize = 10000) {
    // Atomically increment the global counter
    const newMax = await client.incrby('global:url_id_counter', blockSize);
    this.currentOffset = newMax - blockSize;
    this.currentMax = newMax;
    return this.currentOffset;
  }

  async getNextID() {
    if (this.currentOffset >= this.currentMax) {
      await this.claimIDBlock();
    }
    const id = this.currentOffset;
    this.currentOffset++;
    return id;
  }

  async generateShortCode() {
    const id = await this.getNextID();
    return encodeBase62(id);
  }
}

// Usage
const generator = new IDGenerator('instance-3');
const shortCode = await generator.generateShortCode(); // e.g., "L2xP9k"

Approach C: Pre-Generated Code Pool (Snowflake-Style)

Generate codes in advance and store them in a queue. When a URL needs shortening, pop a code from the pool. This decouples code generation from API response time but adds operational complexity.

Step 2: Database Schema Design

For 1 million users, you need a schema optimized for extremely fast reads and write throughput. Use a primary key on the short code and add proper indexing.


-- PostgreSQL schema for URL shortener (1M+ users)
CREATE TABLE url_mappings (
    short_code VARCHAR(10) PRIMARY KEY,   -- Base62 encoded ID
    original_url TEXT NOT NULL,
    user_id BIGINT,                        -- optional: track which user created it
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    expires_at TIMESTAMP WITH TIME ZONE,   -- optional: auto-expiring links
    click_count BIGINT DEFAULT 0,          -- denormalized counter for analytics
    last_accessed_at TIMESTAMP WITH TIME ZONE
);

-- Index for user-level queries
CREATE INDEX idx_url_mappings_user_id ON url_mappings(user_id);

-- Index for expiration cleanup
CREATE INDEX idx_url_mappings_expires_at ON url_mappings(expires_at)
    WHERE expires_at IS NOT NULL;

-- Partitioning by hash for horizontal scaling (optional)
-- Consider partitioning on hash of short_code for multi-tenancy

For even higher scale, consider a NoSQL approach with DynamoDB or Cassandra, where the partition key is the short code:


// DynamoDB table design (AWS SDK v3)
// Table: url_mappings
// Primary Key: short_code (String) — partition key
// Attributes: original_url, user_id, created_at, ttl, click_count

const createTableCommand = {
  TableName: 'url_mappings',
  KeySchema: [
    { AttributeName: 'short_code', KeyType: 'HASH' }  // partition key
  ],
  AttributeDefinitions: [
    { AttributeName: 'short_code', AttributeType: 'S' },
    { AttributeName: 'user_id', AttributeType: 'N' }
  ],
  GlobalSecondaryIndexes: [{
    IndexName: 'user-id-index',
    KeySchema: [{ AttributeName: 'user_id', KeyType: 'HASH' }],
    Projection: { ProjectionType: 'KEYS_ONLY' }
  }],
  BillingMode: 'PAY_PER_REQUEST'  // scales automatically with traffic
};

Step 3: Building the REST API

Your API needs two core endpoints: one for creating short URLs, another for redirecting. Here's a production-grade implementation using Node.js and Express:


const express = require('express');
const rateLimit = require('express-rate-limit');
const { Pool } = require('pg');
const redis = require('ioredis');

const app = express();
app.use(express.json());

// PostgreSQL connection pool — tuned for high concurrency
const dbPool = new Pool({
  max: 50,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

// Redis for caching and ID generation
const cache = new redis({
  maxRetriesPerRequest: 3,
  enableOfflineQueue: false,
});

// Rate limiter: 100 requests per minute per IP
const createLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, slow down.' }
});

// ---------- POST /api/shorten ----------
// Creates a new short URL
app.post('/api/shorten', createLimiter, async (req, res) => {
  try {
    const { url, customCode, ttlDays } = req.body;

    // Validate URL
    if (!url || !isValidUrl(url)) {
      return res.status(400).json({ error: 'Invalid URL provided' });
    }

    // Validate custom code if provided
    let shortCode;
    if (customCode) {
      if (customCode.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(customCode)) {
        return res.status(400).json({ error: 'Custom code must be alphanumeric, max 20 chars' });
      }
      // Check availability
      const exists = await dbPool.query('SELECT short_code FROM url_mappings WHERE short_code = $1', [customCode]);
      if (exists.rows.length > 0) {
        return res.status(409).json({ error: 'Custom code already taken' });
      }
      shortCode = customCode;
    } else {
      // Generate unique code
      const generator = require('./idGenerator');
      shortCode = await generator.generateShortCode();
    }

    // Calculate TTL
    const expiresAt = ttlDays
      ? new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString()
      : null;

    // Insert into database
    await dbPool.query(
      `INSERT INTO url_mappings (short_code, original_url, expires_at, created_at)
       VALUES ($1, $2, $3, NOW())`,
      [shortCode, url, expiresAt]
    );

    // Warm the cache immediately
    await cache.set(`url:${shortCode}`, url, 'EX', ttlDays ? ttlDays * 86400 : 86400 * 30);

    res.status(201).json({
      shortCode,
      shortUrl: `${process.env.BASE_URL}/${shortCode}`,
      originalUrl: url,
      expiresAt,
    });
  } catch (err) {
    console.error('Shorten error:', err);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// ---------- GET /:shortCode ----------
// Redirects to the original URL
app.get('/:shortCode', async (req, res) => {
  try {
    const { shortCode } = req.params;

    // 1. Check cache first — this handles 95%+ of traffic
    const cachedUrl = await cache.get(`url:${shortCode}`);
    if (cachedUrl) {
      // Async update click count — don't block the redirect
      dbPool.query(
        `UPDATE url_mappings SET click_count = click_count + 1, last_accessed_at = NOW()
         WHERE short_code = $1`,
        [shortCode]
      ).catch(() => {}); // fire-and-forget

      return res.redirect(301, cachedUrl);
    }

    // 2. Cache miss — query database
    const result = await dbPool.query(
      `SELECT original_url, expires_at FROM url_mappings WHERE short_code = $1`,
      [shortCode]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Short URL not found' });
    }

    const { original_url, expires_at } = result.rows[0];

    // Check expiration
    if (expires_at && new Date(expires_at) < new Date()) {
      return res.status(410).json({ error: 'This link has expired' });
    }

    // Populate cache with TTL
    const ttlSeconds = expires_at
      ? Math.max(1, Math.floor((new Date(expires_at) - Date.now()) / 1000))
      : 86400 * 30;

    await cache.set(`url:${shortCode}`, original_url, 'EX', ttlSeconds);

    // Async click count update
    dbPool.query(
      `UPDATE url_mappings SET click_count = click_count + 1, last_accessed_at = NOW()
       WHERE short_code = $1`,
      [shortCode]
    ).catch(() => {});

    return res.redirect(301, original_url);
  } catch (err) {
    console.error('Redirect error:', err);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// URL validation helper
function isValidUrl(str) {
  try {
    const url = new URL(str);
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
}

app.listen(3000, () => console.log('URL shortener running on port 3000'));

Step 4: Caching Strategy for Massive Read Throughput

For 1 million users, reads (redirects) will outnumber writes 10:1 or more. A robust caching layer is essential. Here's the strategy:


// Advanced cache configuration with Redis Cluster (for 1M+ users)
const RedisCluster = require('ioredis').Cluster;

const cacheCluster = new RedisCluster([
  { host: 'redis-node-1', port: 6379 },
  { host: 'redis-node-2', port: 6379 },
  { host: 'redis-node-3', port: 6379 },
  { host: 'redis-node-4', port: 6379 },
  { host: 'redis-node-5', port: 6379 },
  { host: 'redis-node-6', port: 6379 },
], {
  scaleReads: 'slave',           // distribute reads across replicas
  retryDelayOnFailover: 100,
  maxRedirections: 16,
});

// Cache key design
// Pattern:  url:{shortCode}  →  original URL
// TTL sync: if DB row has expires_at, cache TTL = expires_at - now
// Fallback:  cache miss → DB query → populate cache → return

async function getUrlWithCache(shortCode) {
  const cacheKey = `url:${shortCode}`;

  // Try cache
  const cached = await cacheCluster.get(cacheKey);
  if (cached) return cached;

  // Cache miss — acquire distributed lock to prevent thundering herd
  const lockKey = `lock:url:${shortCode}`;
  const locked = await cacheCluster.set(lockKey, '1', 'NX', 'PX', 500);

  if (locked) {
    // This instance is responsible for fetching from DB
    const result = await dbPool.query(
      'SELECT original_url, expires_at FROM url_mappings WHERE short_code = $1',
      [shortCode]
    );
    if (result.rows.length > 0) {
      const { original_url, expires_at } = result.rows[0];
      const ttl = calculateTTL(expires_at);
      await cacheCluster.set(cacheKey, original_url, 'PX', ttl);
      await cacheCluster.del(lockKey);
      return original_url;
    }
    await cacheCluster.del(lockKey);
    return null;
  }

  // Wait and retry cache (another instance is fetching)
  await new Promise(resolve => setTimeout(resolve, 100));
  return await cacheCluster.get(cacheKey) || getUrlWithCache(shortCode);
}

function calculateTTL(expiresAt) {
  if (!expiresAt) return 30 * 24 * 3600 * 1000; // 30 days default
  const remaining = new Date(expiresAt).getTime() - Date.now();
  return Math.max(1000, remaining); // minimum 1 second
}

Step 5: Handling High-Concurrency Edge Cases

Custom Code Race Conditions

When users request custom short codes, two simultaneous requests for the same code could both pass the availability check before either inserts. Use database unique constraints as the ultimate guard:


// Safe custom code insertion with ON CONFLICT handling
async function createWithCustomCode(code, url) {
  try {
    const result = await dbPool.query(
      `INSERT INTO url_mappings (short_code, original_url, created_at)
       VALUES ($1, $2, NOW())
       ON CONFLICT (short_code) DO NOTHING
       RETURNING short_code`,
      [code, url]
    );
    if (result.rows.length === 0) {
      throw new Error('Code already taken — retry with different code');
    }
    return code;
  } catch (err) {
    // Let the user know to pick another custom code
    throw err;
  }
}

Preventing Malicious URL Submission

Users could submit URLs pointing to phishing sites, malware, or internal services. Implement URL validation and filtering:


// URL safety checks before shortening
const BLOCKED_DOMAINS = new Set(['localhost', '127.0.0.1', '0.0.0.0', 'internal-api']);
const BLOCKED_PROTOCOLS = new Set(['file:', 'ftp:', 'javascript:', 'data:']);

function validateAndSanitizeUrl(rawUrl) {
  let parsed;
  try {
    parsed = new URL(rawUrl);
  } catch {
    throw new Error('Malformed URL');
  }

  // Block dangerous protocols
  if (BLOCKED_PROTOCOLS.has(parsed.protocol)) {
    throw new Error(`Protocol ${parsed.protocol} is not allowed`);
  }

  // Block internal/private hosts
  const hostname = parsed.hostname.toLowerCase();
  if (BLOCKED_DOMAINS.has(hostname) || hostname.startsWith('192.168.') ||
      hostname.startsWith('10.') || hostname === '[::1]') {
    throw new Error('Internal URLs are not permitted');
  }

  // Enforce HTTPS for production (optional but recommended)
  if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
    throw new Error('Only HTTPS URLs are accepted in production');
  }

  return parsed.href; // normalized URL
}

Step 6: Analytics and Click Tracking

For 1 million users, you need analytics that don't slow down redirects. Use asynchronous, fire-and-forget updates:


// Asynchronous click tracking — never blocks the redirect
async function trackClickAsync(shortCode, metadata) {
  const clickData = {
    short_code: shortCode,
    timestamp: new Date().toISOString(),
    user_agent: metadata.userAgent?.substring(0, 200),
    ip_hash: hashIP(metadata.ip),  // privacy-conscious: hash, don't store raw IP
    referrer: metadata.referrer?.substring(0, 500),
    country: metadata.geoCountry,
  };

  // Push to a message queue for batch processing
  // This avoids slowing down the redirect response
  await messageQueue.push('click-events', clickData);

  // Also increment a Redis counter for real-time dashboard
  await cache.hincrby(`stats:${shortCode}`, 'clicks', 1);
  await cache.expire(`stats:${shortCode}`, 86400 * 30);
}

// Batch processor (runs separately, e.g., via worker process)
async function batchProcessClicks() {
  const batch = await messageQueue.popBatch('click-events', 100);
  if (batch.length === 0) return;

  const values = batch.map((e, i) =>
    `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})`
  ).join(', ');

  const params = batch.flatMap(e => [
    e.short_code, e.timestamp, e.user_agent, e.ip_hash, e.referrer, e.country
  ]);

  await dbPool.query(
    `INSERT INTO click_events (short_code, timestamp, user_agent, ip_hash, referrer, country)
     VALUES ${values}`,
    params
  );
}

Step 7: Deployment and Scaling Considerations

When deploying for 1 million users, consider these infrastructure patterns:


// Environment-based configuration for different scale tiers
const config = {
  // Small scale (< 10K users) — single server
  small: {
    dbPoolMax: 20,
    cacheInstance: 'single',
    apiInstances: 1,
    rateLimitPerMinute: 30,
  },
  // Medium scale (< 500K users) — multiple servers, read replicas
  medium: {
    dbPoolMax: 50,
    dbReadReplicas: ['replica1.internal', 'replica2.internal'],
    cacheCluster: true,
    apiInstances: 4,
    rateLimitPerMinute: 100,
  },
  // Large scale (1M+ users) — full distributed deployment
  large: {
    dbPoolMax: 100,
    dbReadReplicas: 8,
    cacheClusterNodes: 12,
    apiInstances: 16,
    rateLimitPerMinute: 200,
    cdnEnabled: true,
    geoDistributed: true,  // deploy to multiple regions
  },
};

function loadConfig() {
  const tier = process.env.SCALE_TIER || 'medium';
  return config[tier];
}

Best Practices Summary

Conclusion

Designing a URL shortener for 1 million users is a classic system design challenge that touches on hashing strategies, distributed ID generation, caching architectures, and database scaling patterns. The key insight is that reads dominate writes by an order of magnitude — your architecture should optimize for sub-millisecond redirects while keeping URL creation reliable and collision-free. By combining Base62-encoded distributed counters, a Redis cluster for caching, PostgreSQL (or DynamoDB) with read replicas, and asynchronous analytics processing, you can build a system that handles millions of URLs and hundreds of millions of redirects per month with confidence. Start with the counter-based approach, implement write-through caching from day one, validate every URL rigorously, and always keep the redirect path as lean as possible. With these patterns in place, scaling to 1 million users becomes an exercise in incremental infrastructure growth rather than a fundamental redesign.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles