System Design ⚑ Recently updatedUpdated on Jun 30, 2026 β€” this content may still be evolving.

Design a URL Shortener (Student-Level Walkthrough)

The classic system design warm-up. Compact but covers hashing, redirects, storage, caching, and analytics.


Step 1: Clarify Requirements (2–3 min)

QuestionAssumed answer
Core function?Convert long URL β†’ short URL; short URL redirects to original
Custom aliases?Optional β€” user can pick short.ly/my-link if available
Expiration?Optional TTL per link (default: never)
Analytics?Track click count and timestamp per click
Scale?10M URLs stored, 100M redirects/month
Who creates links?Anyone (public) or logged-in users only? β†’ Logged-in users

Functional requirements:

  1. Submit long URL β†’ receive short URL
  2. Visit short URL β†’ 301/302 redirect to original
  3. Optional custom alias
  4. View click analytics for own links

Non-functional requirements:

  • Redirect latency < 100ms (p95)
  • Short codes should be unique
  • System available 99.9%

Step 2: Rough Estimates (2 min)

MetricCalculation
Writes10M URLs total; ~4 new URLs/sec average
Reads100M/month β‰ˆ 40 redirects/sec average; peak ~200/sec
Read:write ratio~50:1 (very read-heavy β†’ cache aggressively)
Storage (10M URLs)10M Γ— ~500 bytes/row β‰ˆ 5 GB
7-char base62 codes62^7 β‰ˆ 3.5 trillion combinations (plenty of space)

Step 3: High-Level Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  POST /api/urls     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Client  β”‚ ──────────────────►│  API Server β”‚
β”‚          β”‚  GET /:code         β”‚  (Node.js)  β”‚
β”‚          β”‚ ◄── 302 Redirect ──│             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                                        β”‚
                               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
                               β–Ό        β–Ό        β–Ό
                          [Redis]  [Postgres] [Analytics]
                          (cache)   (urls)     (clicks)

Read path (hot β€” 99% of traffic):

  1. GET /abc123 hits API (or dedicated redirect service)
  2. Check Redis: url:abc123 β†’ long URL
  3. Cache hit β†’ 302 redirect immediately
  4. Cache miss β†’ query Postgres β†’ populate cache β†’ redirect
  5. Async: log click to analytics (don’t block redirect)

Step 4: Database Schema

urls (
  id            BIGSERIAL PK,
  short_code    VARCHAR(10) UNIQUE NOT NULL,  -- e.g. "abc123"
  long_url      TEXT NOT NULL,
  user_id       UUID FK β†’ users,              -- who created it
  custom_alias  BOOLEAN DEFAULT FALSE,
  expires_at    TIMESTAMPTZ,                  -- NULL = never
  created_at    TIMESTAMPTZ DEFAULT NOW()
)
-- INDEX: short_code (unique, primary lookup)

clicks (
  id            BIGSERIAL PK,
  url_id        BIGINT FK β†’ urls,
  clicked_at    TIMESTAMPTZ DEFAULT NOW(),
  referrer      VARCHAR(500),
  user_agent    VARCHAR(500),
  ip_hash       VARCHAR(64)                  -- hashed, not raw IP (privacy)
)
-- INDEX: (url_id, clicked_at DESC) for analytics queries

Step 5: API Endpoints

MethodEndpointDescription
POST/api/v1/urlsCreate short URL
GET/:shortCodeRedirect to long URL
GET/api/v1/urls/:code/statsClick analytics (owner only)
DELETE/api/v1/urls/:codeDelete link (owner only)

Create Short URL

// Request
POST /api/v1/urls
{
  "longUrl": "https://example.com/very/long/path?with=params",
  "customAlias": "my-demo",       // optional
  "expiresInDays": 30             // optional
}

// Response 201
{
  "shortUrl": "https://short.ly/my-demo",
  "shortCode": "my-demo",
  "longUrl": "https://example.com/very/long/path?with=params",
  "expiresAt": "2025-07-30T00:00:00Z"
}

Step 6: Deep Dive β€” Generating Short Codes

const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

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

// id = 125 β†’ "1Z"
// id = 1000000 β†’ "4c92"

Flow:

  1. Insert long URL into DB β†’ get auto-increment id
  2. Convert id to base62 β†’ short_code
  3. Update row with short_code

Pros: No collisions, deterministic, short codes grow slowly Cons: Predictable (users can guess next code β€” acceptable for non-sensitive URLs)

Approach 2: Random String

function randomCode(length = 7) {
  return crypto.randomBytes(length).toString('base64url').slice(0, length);
}

Pros: Unpredictable Cons: Must check for collisions and retry

Approach 3: Hash (MD5/SHA of URL)

Don’t use alone β€” same URL β†’ same hash (good for dedup) but long hashes need truncation (collision risk).

Hybrid: Hash for dedup check + auto-increment for unique code.

Custom Alias

async function createUrl({ longUrl, customAlias }) {
  if (customAlias) {
    const exists = await db.findByCode(customAlias);
    if (exists) throw new ConflictError('Alias already taken');
    // validate: alphanumeric + hyphens only, 3-20 chars
    return db.insert({ short_code: customAlias, long_url: longUrl, custom_alias: true });
  }
  // auto-generate via base62
}

Step 7: Deep Dive β€” Redirect Path

// Express route
app.get('/:code', async (req, res) => {
  const { code } = req.params;

  // 1. Cache lookup
  let longUrl = await redis.get(`url:${code}`);
  if (!longUrl) {
    const row = await db.query('SELECT long_url, expires_at FROM urls WHERE short_code = $1', [code]);
    if (!row) return res.status(404).send('Not found');
    if (row.expires_at && row.expires_at < new Date()) return res.status(410).send('Expired');
    longUrl = row.long_url;
    await redis.setex(`url:${code}`, 3600, longUrl); // cache 1 hour
  }

  // 2. Async analytics (don't await β€” don't slow redirect)
  logClick(code, req).catch(console.error);

  // 3. Redirect
  res.redirect(302, longUrl);  // 302 = temporary (allows changing destination)
  // Use 301 if URL never changes (better for SEO, browser caches aggressively)
});

301 vs 302

301 Permanent302 Temporary
Browser behaviorCaches redirectDoesn’t cache
Use whenURL mapping never changesMight update destination
AnalyticsBrowser may not hit server againEvery click hits server

For URL shortener: 302 is safer (allows editing destination). Mention 301 tradeoff.


Step 8: Caching Strategy

DataWhereTTLWhy
short_code β†’ long_urlRedis1 hour50:1 read:write ratio
Popular codesRedis (always hot)Refresh on access80/20 rule β€” few codes get most clicks

Cache invalidation:

  • On URL delete β†’ redis.del(url:${code})
  • On URL update (if allowed) β†’ delete cache key

At scale: Dedicated redirect service (separate from API) that only does cache lookup + redirect. API server handles creates/analytics.


Step 9: Analytics (Async)

Don’t block the redirect for analytics.

Redirect path β†’ fire-and-forget β†’ queue (Redis list / SQS)
                                        β”‚
                                   Worker batches inserts
                                   into clicks table every 5 seconds

Stats endpoint:

SELECT DATE(clicked_at) as day, COUNT(*) as clicks
FROM clicks
WHERE url_id = $1
GROUP BY day
ORDER BY day DESC
LIMIT 30;

Step 10: Security

ThreatMitigation
Open redirect abuseValidate longUrl is http/https; block internal IPs (SSRF)
Spam URL creationRate limit: 10 URLs/hour per user, CAPTCHA for anonymous
Malicious URLsOptional: check against malware blocklist API
EnumerationRandom codes instead of sequential (if privacy matters)
PhishingDisplay interstitial warning page for flagged domains (advanced)
function validateLongUrl(url) {
  const parsed = new URL(url);
  if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol');
  if (isPrivateIP(parsed.hostname)) throw new Error('Internal URLs not allowed');
}

Step 11: Tradeoffs Summary

DecisionChoiceWhy
Code generationAuto-increment + base62No collisions, simple
Redirect302 + Redis cacheFast, allows destination updates
AnalyticsAsync queueDoesn’t slow redirect path
DatabasePostgreSQLACID, relational analytics queries
Scale pathSeparate redirect service + CDN edgeRedirect is 99% of traffic

Scale Path (Mention If Asked)

UsersArchitecture change
10M URLsSingle Postgres + Redis β€” fine
1B URLsShard urls table by short_code prefix
1M RPS redirectsRedis cluster; redirect service at edge (Cloudflare Workers)
GlobalGeo-distributed Redis replicas

Whiteboard Diagram

POST /urls β†’ [API] β†’ [Postgres] β†’ return short.ly/abc123

GET /abc123 β†’ [Redirect Service] β†’ [Redis hit?] β†’ 302 β†’ long URL
                                    [miss] β†’ [Postgres] β†’ cache β†’ 302
                                    [async] β†’ [Click Queue] β†’ [Analytics DB]

Common Follow-Up Questions

Q: How many URLs fit in 7 base62 characters? A: 62^7 β‰ˆ 3.5 trillion. At 10M URLs, you’re using 0.0003% of the space.

Q: What if two users submit the same long URL? A: Option A: create duplicate short codes (simple). Option B: dedup β€” return existing short code (saves storage).

Q: How do you handle a viral link with 1M clicks/hour? A: Redis cache absorbs reads. Async analytics queue. CDN edge redirect for known hot codes.


Why interviewers love this question: It touches encoding, caching, redirects, read-heavy optimization, and security β€” all in a problem you can fully explain in 20 minutes.