System Design
C1

URL shortener: short codes, fast redirects

Turn a long URL into a 7-character code and redirect every click. Writes are easy; the redirect path and the code generator decide the design.

Not startedSaved in this browser only.
  1. 1Reads outnumber writes 100 to 1. Design the redirect path first: Redis in front, Postgres behind.
  2. 27 base62 characters give 3.5 trillion codes, enough for 10 years at 100 million links a day.
  3. 3Let the primary key catch collisions. Key ranges per server need one database call per 1,000 codes.
  4. 4Use 302 with no-store to count clicks, and count them in memory, in batches.
C1
    A

    The prompt and requirements

    ask, then write numbers
    "Design a service that turns a long URL into a short one and redirects, at the scale of a large consumer site."
    questionanswer assumed
    How many new links a day?100 million
    Reads per write?100 redirects per link
    How long do links live?10 years by default; optional expiry
    Custom aliases?Yes, for signed-in users
    Click analytics?Clicks per link per day
    Can a link change target?No; delete and create
    functional
    Shorten a URL. Redirect a code. Custom alias. Expiry. Clicks per day.
    latency
    Redirect p99 under 50 ms at the server; create p99 under 300 ms.
    availability
    Redirect 99.99%: a broken redirect breaks every page that links to it. Create 99.9%.
    durability
    A created link is never lost or changed.

    I assume 100 million new links a day, 100 redirects per link, links kept 10 years, and redirects in under 50 ms at p99.

    B

    Estimates

    the conversions, worked
    numberarithmeticresult
    creates, average100 M ÷ 86,400 s1,157/s
    creates, peak (3×)1,157 × 33,472/s
    redirects, average100 × 100 M ÷ 86,400 s115,741/s
    redirects, peak (3×)115,741 × 3347,222/s
    storage a day100 M × 500 B50 GB
    storage, 10 years50 GB × 3,650183 TB, 1 copy
    links, 10 years100 M × 3,650365 billion
    codes, 7 chars62⁷3.52 trillion
    space used at 10 years365 B ÷ 3.52 T10.4%
    bandwidth out, peak347,222 × 500 B × 81.4 Gbit/s
    cache, 1% of a year365 M × 551 B201 GB

    500 B per link row and per redirect response, and the 3× peak, are assumptions; say them out loud. 551 B is Redis memory for one cached link with a 500 B URL, measured in the lab. The method is on the R2 sheet.

    Writes peak at about 3,500 a second; redirects at about 350,000. Ten years of links is 183 TB, so the data needs about 19 shards.

    C

    API

    two calls carry the load
    callbodysuccesserrorsretry safe by
    POST /api/links + Idempotency-Key{url, alias?, expires_in_days?}201 {code}400 bad URL, 409 alias taken, 429the key: a retry gets the first code
    GET /{code}none302 + Location404 unknown, 410 expireda read
    GET /api/links/{code}/clicks?from=&to=none200 [{day, clicks}]403 not owner, 404a read
    DELETE /api/links/{code}none204403 not owner, 404DELETE is idempotent
    • The redirect lives at the root path, so the short URL stays short. Reserve the paths the site itself uses.
    • Without an Idempotency-Key, a create retried after a timeout makes a second code. With it, the unique index returns the first.
    • A deleted code is not reused. Old pages may still link to it, so it answers 404 or 410 for ever.

    Create is a POST with an Idempotency-Key. The redirect is a GET on the short path that answers 302, 404 or 410.

    D

    Data model

    entities, then SQL
    1 link : n dayscached copylinksPKcode7 chars, base62long_urlup to 2 KBowner_idnullablecustomalias or notexpires_atnullableUQidem_keyretry safeshard by hash(code)key_rangesPKnamenext_startbigint1 UPDATE per blockclicks_dailyPKcodePKdayclicksbigintupsert: clicks + nlink:<code>valuelong URL, or "" if missingTTL ≤ 24 h, ≤ expires_at
    rows
    365 billion links in 10 years; 183 TB for one copy
    shard
    by hash(code): a redirect reads one shard
    cache key
    link:<code> holds the long URL; an empty value means "no such code"
    links, key ranges, clickssql
    -- One row per short link. The primary key refuses a duplicate code.
    CREATE TABLE links (
      code       text        PRIMARY KEY1,
      long_url   text        NOT NULL,
      owner_id   bigint,
      custom     boolean     NOT NULL DEFAULT false,
      created_at timestamptz NOT NULL DEFAULT now(),
      expires_at timestamptz,
      idem_key   text        UNIQUE2,
      CHECK (length(code) BETWEEN 4 AND 30)
    );
    
    -- Expired links leave in batches; this index finds them.
    CREATE INDEX links_expires_at ON links (expires_at)
      WHERE expires_at IS NOT NULL3;
    
    -- One counter per generator. A server claims a block with one UPDATE.
    CREATE TABLE key_ranges (
      name       text   PRIMARY KEY,
      next_start bigint NOT NULL4
    );
    
    -- Clicks per link per day, written in batches.
    CREATE TABLE clicks_daily (
      code   text   NOT NULL,
      day    date   NOT NULL,
      clicks bigint NOT NULL,
      PRIMARY KEY (code, day)5
    );
    1. 1The primary key is the collision check. A duplicate insert changes 0 rows, and the generator tries again.
    2. 2A retried create finds its key and returns the first code.
    3. 3A partial index: most links never expire, so the cleanup index stays small.
    4. 4One row per generator. A server claims a block by adding 1,000 to it.
    5. 5One row per link and day. Batched clicks add to it.

    One table keyed by code. A redirect is one primary-key read, so the table shards by a hash of the code.

    E

    The architecture

    click a path; its edges light up
    Browseror appLoad balancerTLS, spreads loadLink servicestateless, N copiesCleanup jobdeletes expiredRedislink:<code>, TTL 24 hPostgreslinks, ranges, clicks

    Step 1: Create

    • Check the long URL: http or https, at most 2 KB, not this domain, not on the block list.
    • Take the next code from this server's block. One block claim per 1,000 codes.
    • Insert the row. The primary key refuses a duplicate code.
    • Return 201 with the short URL.

    If it fails

    A duplicate code: try the next one. Postgres down: return 503; the client retries with the same Idempotency-Key and gets one link.

    Creates go to Postgres. Redirects read Redis first and Postgres on a miss. Clicks and expiry run in the background.

    F

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresPrimary key and INSERT ... ON CONFLICT DO NOTHINGThe insert is the collision check. 0 rows changed means "try the next code".Idempotent writes, dedupe
    PostgresUPDATE ... RETURNING on one counter rowClaim a block of 1,000 values in one statement. The row lock puts concurrent claims in a queue.Invoice numbers, ticket numbers
    PostgresUnique index on the idempotency keyA retried create returns the first code.Payments, bookings
    PostgresUpsert that adds: clicks = clicks + nA batch of counts merges into one row per link and day.Rollups, counters
    PostgresPartial index on expires_atThe cleanup job finds expired rows without a full scan.Soft deletes, job queues
    RedisGET and SET with an expiryCache-aside for redirects. The TTL never outlives the link.Sessions, page caches
    RedisAn empty value with a 60 s TTLA negative cache. A scan of random codes reaches Postgres once per code, not once per request.Cache penetration defence
    RedisINCRA single global counter for the counter generator, atomic across servers.Rate limits, sequence numbers
    RedisAsynchronous replicationLimit A failover can lose recent INCRs, so the counter can repeat. The primary key catches it.
    ServiceSHA-256, base62, a multiply modulo 62⁷Deterministic codes from a URL; non-sequential codes from a counter, with no collision.ID obfuscation
    HTTP301 or 302 with Cache-ControlDecides whether browsers and CDNs repeat the redirect without asking the service.Moved pages, login flows

    Postgres gives me a primary key that refuses duplicate codes and a counter row I can claim blocks from. Redis gives me a cache with expiry.

    G

    Deep dive: generating the code

    no collision, no coordination per request
    methodwherestatus
    Key ranges per server, scrambledPostgresApproved
    Random 7 chars, insert, retry on a duplicatePostgresApproved
    Hash the URL, truncate, salt on a collisionServiceDedupe wanted
    One global counter, base62RedisScramble it
    64-bit time-ordered ID, base62Service11 chars
    UUID, base62Service22 chars
    SELECT to check, then INSERTPostgresRace
    key_rangesnext_start = 3000one UPDATE per block,row lock serializes claimsServer Ain memory[0, 1000)620 handed outServer Bin memory[1000, 2000)350 handed outServer Cin memory[2000, 3000)crash700 skipped forevereach value is scrambled into a 7-character code
    • A hash gives the same URL the same code, which is dedupe for free. But two users then share one link, with one expiry and one owner.
    • A raw counter is guessable: anyone can walk every link. Multiply by a constant that shares no factor with 62⁷, modulo 62⁷. Every value still gets its own code.
    • Lab, 8 servers at once: 160,000 codes, 160,000 distinct, 160 database calls.
    create a linkpseudo code
    shorten(url, idem_key):
      FOR attempt IN 0 .. 4:
        code = source.next(url, attempt)       // hash + salt, random, or a range1
        IF insert(code, url, idem_key) adds 1 row2:
          RETURN code
        IF idem_key is stored: RETURN its code   // a retried request3
        IF the row at code has this url:
          RETURN code                            // same URL, same hash4
      FAIL "no free code"
    
    next_from_range():                         // key ranges
      IF this block is used up:
        start = UPDATE counter row: next += 1000, RETURNING old value5
        block = [start, start + 1000)          // nobody else gets it
      RETURN scramble(next value of block)6     // 7 characters
    1. 1Every generator plugs into one loop. Only hash and random can return a taken code.
    2. 2INSERT ... ON CONFLICT DO NOTHING. A taken code or a reused key changes 0 rows and raises no error.
    3. 3The client timed out and sent the same Idempotency-Key. It gets the first code; no second link.
    4. 4Hash and truncate only: this URL is stored already, so its code is the answer.
    5. 5One row, one lock. Two servers that claim at once get two different blocks.
    6. 6A multiply modulo 62⁷. It is a bijection, so it hides the order and never collides.
    Tested source Go: the create loop · Go: claim a block · SQL: claim a block · Go: scramble · Go: hash and truncate
    Go: the create loopgo
    
    // Shorten stores l under a code from src. It retries with the next candidate when the code is
    // taken. A retried request with the same idempotency key gets the first code back. A hashed URL
    // that is already stored under its code also returns that code.
    func (s *Service) Shorten(ctx context.Context, src Source, l Link) (string, int, error) {
      for attempt := range MaxAttempts {
        code, err := src.Candidate(ctx, l.LongURL, attempt)
        if err != nil {
          return "", attempt + 1, err
        }
        l.Code = code
        ok, err := s.Store.insert(ctx, l)
        if err != nil {
          return "", attempt + 1, err
        }
        if ok {
          s.forget(ctx, code)
          return code, attempt + 1, nil
        }
        if l.IdemKey != "" {
          first, found, err := s.Store.codeForIdemKey(ctx, l.IdemKey)
          if err != nil {
            return "", attempt + 1, err
          }
          if found {
            return first, attempt + 1, nil
          }
        }
        stored, _, err := s.Store.Find(ctx, code)
        if err != nil && !errors.Is(err, ErrNotFound) {
          return "", attempt + 1, err
        }
        if stored == l.LongURL {
          return code, attempt + 1, nil
        }
      }
      return "", MaxAttempts, ErrNoFreeCode
    }
    
    Go: claim a blockgo
    
    // Candidate implements Source. Only the first code of each block costs a database call.
    func (r *RangeSource) Candidate(ctx context.Context, _ string, _ int) (string, error) {
      r.mu.Lock()
      defer r.mu.Unlock()
      if r.next == r.end {
        var start int64
        if err := r.store.db.QueryRow(ctx, sqls["claim_range"], r.name, int64(r.block)).Scan(&start); err != nil {
          return "", fmt.Errorf("claim a block of %d from %s: %w", r.block, r.name, err)
        }
        r.next, r.end = uint64(start), uint64(start)+r.block
        r.claims++
      }
      n := r.next
      r.next++
      return Scramble(n, r.length)
    }
    
    SQL: claim a blocksql
    UPDATE key_ranges
    SET next_start = next_start + $2
    WHERE name = $1
    RETURNING next_start - $2;
    Go: scramblego
    
    // Scramble maps counter value n to a code that does not look sequential. It is a bijection on
    // [0, 62^length), so it never creates a collision. It hides the order; it is not encryption.
    func Scramble(n uint64, length int) (string, error) {
      space := Space(length)
      if n >= space {
        return "", fmt.Errorf("counter %d does not fit in %d characters (%d codes)", n, length, space)
      }
      return Base62(mulMod(n, scrambleFactor%space, space), length), nil
    }
    
    Go: hash and truncatego
    
    // HashValue is the first 8 bytes of SHA-256(url + "#" + salt). The salt changes the code when two
    // different URLs land on the same one.
    func HashValue(url string, salt int) uint64 {
      sum := sha256.Sum256([]byte(url + "#" + strconv.Itoa(salt)))
      return binary.BigEndian.Uint64(sum[:8])
    }
    
    // HashCode truncates the hash to length base62 characters.
    func HashCode(url string, salt, length int) string {
      return Base62(HashValue(url, salt)%Space(length), length)
    }
    

    I take codes from a block of counter values that each server claims from Postgres. Random codes with a retry also work at 7 characters.

    H

    Deep dive: the redirect path

    cache, then the primary key
    302 Found · no-storeBrowserServiceTargetclick 1GET /Abc1234302 + LocationGET long URLclick 2GET /Abc1234302 + LocationGET long URLservice counts 2 clicks301 Moved · max-age 1 dayBrowserServiceTargetclick 1GET /Abc1234301 + LocationGET long URLclick 2browser cacheGET long URLservice counts 1 click of 2
    answerwho caches itstatus
    302 + no-storeNobody. Every click reaches the service.Count clicks
    301 + max-ageBrowser and CDN, until max-age endsNo analytics
    301, no Cache-ControlBrowsers may keep it with no endNot approved
    200 + HTML refreshNobody; one more round tripWarning page
    redirectpseudo code
    redirect(code):
      url = Redis GET "link:" + code
      IF url == "": RETURN 404                // a cached miss1
      IF url is missing:
        row = Postgres: SELECT by primary key2
        IF no row: cache "" for 60 s; RETURN 404
        IF row expired: RETURN 4103
        cache url for min(24 h, time to expiry)4
      count the click in memory
      RETURN 302, Location: url, Cache-Control: no-store5
    1. 1Redis holds an empty value for codes that do not exist. A scan of random codes stops here.
    2. 2One index lookup on one shard. A Redis error also falls back here; the cache is never the only copy.
    3. 3Gone: the link existed and expired. It is not cached, so it cannot come back.
    4. 4The cache never serves a link after its expiry.
    5. 5The browser asks again on the next click, so the service sees and counts it.
    Tested source Go: resolve through the cache · Go: the HTTP redirect
    Go: resolve through the cachego
    
    // Resolve returns the long URL for code: from Redis when cached, else from Postgres, and then
    // caches the answer. A Redis error falls back to Postgres; the cache is never the only copy.
    func (s *Service) Resolve(ctx context.Context, code string) (string, error) {
      v, err := s.Cache.Get(ctx, CacheKey(code)).Result()
      switch {
      case err == nil && v == missing:
        return "", ErrNotFound
      case err == nil:
        return v, nil
      case !errors.Is(err, redis.Nil):
        s.Log.Warn("link cache unavailable; reading Postgres", "code", code, "err", err)
      }
      s.DBReads.Add(1)
      url, exp, err := s.Store.Find(ctx, code)
      if errors.Is(err, ErrNotFound) {
        s.cache(ctx, code, missing, s.MissTTL)
        return "", ErrNotFound
      }
      if err != nil {
        return "", err
      }
      ttl := s.CacheTTL
      if exp != nil {
        left := exp.Sub(s.Now())
        if left <= 0 {
          return "", ErrExpired
        }
        ttl = min(ttl, left)
      }
      s.cache(ctx, code, url, ttl)
      return url, nil
    }
    
    Go: the HTTP redirectgo
    
    func (h *Handler) redirect(w http.ResponseWriter, r *http.Request) {
      code := strings.TrimPrefix(r.URL.Path, "/")
      long, err := h.Svc.Resolve(r.Context(), code)
      switch {
      case errors.Is(err, ErrNotFound):
        http.NotFound(w, r)
        return
      case errors.Is(err, ErrExpired):
        http.Error(w, "this link has expired", http.StatusGone)
        return
      case err != nil:
        h.Svc.Log.Error("resolve failed", "code", code, "err", err)
        http.Error(w, "try again", http.StatusServiceUnavailable)
        return
      }
      status := http.StatusFound
      w.Header().Set("Cache-Control", "private, no-store")
      if h.Permanent {
        status = http.StatusMovedPermanently
        w.Header().Set("Cache-Control", "public, max-age=86400")
      }
      if h.OnClick != nil {
        h.OnClick(code)
      }
      http.Redirect(w, r, long, status)
    }
    
    lookup by code, labp50p9932 clients
    Redis GET190 µs437 µs56,700/s
    Postgres PK90 µs152 µs84,600/s
    LRU holdsshare of redirects it answers
    0.1% of links60%
    1% of links75%
    10% of links86%

    Both stores answer one lookup in under 1 ms on one machine. The cache exists to take load off Postgres, not to make one lookup faster. LRU replay: 1,000,000 redirects over 1,000,000 links, with popularity on a Zipf curve of exponent 1.1 (an assumption).

    I answer 302 with no-store, so every click reaches me and I can count it. Redis answers most redirects; Postgres answers the misses by primary key.

    I

    Deep dive: counting clicks

    batches, not one write per click
    methodwherestatus
    UPDATE links SET clicks + 1, per clickPostgresHot row
    Count in memory, upsert every 5 sServiceApproved
    INCR per link and dayRedisTotals only
    Log each click to a stream, aggregateKafkaPer-click detail
    HyperLogLog per link and dayRedisUnique visitors
    click counterpseudo code
    on redirect:
      buffer[code, today] += 1                 // memory only1
    
    every 5 s:
      batch = swap buffer with an empty one2
      upsert batch: clicks = clicks + n3        // one statement
      IF it fails: add batch back to buffer4
    1. 1A map update per click. No network call on the redirect path.
    2. 2Redirects keep counting into the new map while the old one is written.
    3. 3ON CONFLICT DO UPDATE adds to the stored total, so batches from many servers merge.
    4. 4A failed write loses nothing. The next flush retries it.
    Tested source Go: flush · SQL: add clicks
    Go: flushgo
    
    // Flush writes every buffered count as one statement: one row per link and day, added to the
    // stored total. A background loop calls it every few seconds.
    func (b *ClickBuffer) Flush(ctx context.Context) error {
      b.mu.Lock()
      batch := b.counts
      b.counts = map[clickKey]int64{}
      b.mu.Unlock()
      if len(batch) == 0 {
        return nil
      }
      codes := make([]string, 0, len(batch))
      days := make([]time.Time, 0, len(batch))
      clicks := make([]int64, 0, len(batch))
      for k, n := range batch {
        codes, days, clicks = append(codes, k.code), append(days, k.day), append(clicks, n)
      }
      if _, err := b.store.db.Exec(ctx, sqls["add_clicks"], codes, days, clicks); err != nil {
        b.restore(batch)
        return fmt.Errorf("flush %d click rows: %w", len(batch), err)
      }
      b.mu.Lock()
      b.flushes++
      b.rowsWritten += len(batch)
      b.mu.Unlock()
      return nil
    }
    
    SQL: add clickssql
    INSERT INTO clicks_daily (code, day, clicks)
    SELECT * FROM unnest($1::text[], $2::date[], $3::bigint[])
    ON CONFLICT (code, day) DO UPDATE SET clicks = clicks_daily.clicks + excluded.clicks;

    Tested: 8 servers clicked 10,000 times each on 100 links while a flusher ran. Every link got exactly 800, written in batches.

    Each redirect server counts clicks in memory and writes one upsert every 5 seconds. At larger scale, I log clicks to a stream and aggregate there.

    J

    Deep dive: aliases, expiry, abuse

    rules at create time
    alias designstatus
    Same table and key as generated codesApproved
    Any shape, same tableClash later
    A separate alias table2 lookups per miss
    aliases and expirypseudo code
    create_alias(alias, url):
      refuse unless 4 to 30 chars of [0-9A-Za-z-_]
      refuse reserved paths: admin, login, static
      refuse 7 plain base62 chars1              // a generated shape
      insert; 0 rows: RETURN 409 taken2
    
    expire():                                   // a job, every minute
      REPEAT: delete 1000 rows3 WHERE expires_at < now
      UNTIL a pass deletes nothing
    1. 1A generated code could equal this alias later. Any other shape can never clash.
    2. 2The same primary key as generated codes. No separate namespace to keep in sync.
    3. 3Short transactions: few row locks, little replication lag.
    Tested source Go: alias rules · SQL: delete expired
    Go: alias rulesgo
    
    // CheckAlias applies the alias rules: 4 to 30 characters from base62 plus - and _, not a reserved
    // path, and not shaped like a generated code.
    func CheckAlias(alias string) error {
      if len(alias) < 4 || len(alias) > 30 {
        return fmt.Errorf("%w: %q must have 4 to 30 characters", ErrBadAlias, alias)
      }
      plain := true
      for _, c := range alias {
        if c == '-' || c == '_' {
          plain = false
          continue
        }
        if !strings.ContainsRune(Alphabet, c) {
          return fmt.Errorf("%w: %q has the character %q", ErrBadAlias, alias, c)
        }
      }
      if reserved[strings.ToLower(alias)] {
        return fmt.Errorf("%w: %q is a reserved path", ErrBadAlias, alias)
      }
      if plain && len(alias) == GeneratedLength {
        return fmt.Errorf("%w: %q looks like a generated code; add - or _, or change the length", ErrBadAlias, alias)
      }
      return nil
    }
    
    SQL: delete expiredsql
    DELETE FROM links
    WHERE code IN (
      SELECT code FROM links
      WHERE expires_at < $1
      LIMIT $2
    );
    abusedefencewhere
    Bulk creates of spam linksRate limit per user and per IP (B3)Redis
    Malware targetsCheck the host against a block list at create; recheck laterService
    A link to the shortener itselfRefuse the own domain, so links cannot loopService
    Walking every codeRandom or scrambled codes; a rate limit on 404sService
    Scans that miss the cacheCache misses for 60 sRedis

    An alias goes through the same primary key as generated codes, so a taken alias is a 409. A rate limit and a block list stop most abuse at create time.

    K

    Try it: codes, collisions, capacity

    recorded from the lab

    Draw a code, insert it; on a duplicate key, draw again. No coordination between servers.

    codes of 7 characters
    3.52 trillion
    627
    lasts, at 100 M links a day
    96.5 years
    codes ÷ 100 M ÷ 365
    collisions in 1 M inserts
    0
    birthday estimate 0.14
    first collision at insert
    none
    estimate 2,351,965
    share of codes taken = chance the first try collides0%25%50%75%100%0246810 years10% at 10 years

    At 10 years, a new code is taken with chance 10%. A create then needs 1.12 tries on average. Each try is one insert that the primary key refuses.

    lengthcollisions in 1 Mestimatefirst atmost tries
    433,99333,8385,1356
    557654650,4772
    648.80348,0152
    700.14none1
    80under 0.01none1

    At 7 characters, 1 million random codes did not collide once. After 10 years at 100 million a day, a random code collides about 1 time in 10, and the retry handles it.

    L

    Failure cases

    what breaks, and why it stays correct
    eventresultwhy it is safesaved by
    Two servers draw the same random codeThe second insert changes 0 rows.It draws again. The lab saw at most 1 try at 7 characters in 1 million creates.Primary key
    Redis fails over and loses counter incrementsThe counter hands out a value twice.The second insert is refused; the loop takes the next value.Primary key
    A server crashes mid-blockThe rest of its block is never used.The counter row moved past it, so no value repeats. Lab: 700 skipped, 0 reused.Counter row
    Redis is downEvery redirect reads Postgres.Redirects get slower, not wrong. Postgres replicas absorb the reads.Fallback
    A create times out and the client retriesThe retry finds its Idempotency-Key.It returns the first code. One link, not two.Unique key
    A cached link reaches its expiryRedis drops it at the same moment.The cache TTL is never longer than the time left. The next read sees 410.TTL
    A 301 is cached, then the link is deletedBrowsers still redirect.Not safe: a 301 cannot be recalled. Use 302 for links that may change or go away.Cache-Control
    The click flush failsThe batch goes back into the buffer.The next flush writes it. A crash loses at most one interval of counts.Buffer
    One link goes viralOne Redis key takes every read.Keep the hottest codes in each server's memory for a few seconds.Local cache
    M

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1Postgres2+ Redis cache3+ replicas4+ shards5+ edge redirectsmore load →
    Capacity against demand1k10k100k1MCreates, peak: 3,472 requests per secondCreates, peak3,472Redirects, peak: 347,222 requests per secondRedirects, peak347,222Misses at 75% hits: 85,417 requests per secondMisses at 75% hits85,417Postgres inserts, lab: 36,465 requests per secondPostgres inserts, lab36,465Postgres lookups, lab: 84,600 requests per secondPostgres lookups, lab84,600Redis lookups, lab: 56,700 requests per secondRedis lookups, lab56,700requests per second, log scale
    stepaddit handlesmove up when you see
    1One Postgres and a stateless service.Creates are no problem: 3,472 a second at peak against about 36,465 inserts measured. Lookups reach about 84,600 a second.Redirects near what one node reads.
    2Redis cache-aside for redirects.An LRU of 1% of links answered 75% of redirects in the lab. Postgres sees the misses: 85,417 a second at peak.The misses alone near one node, or the cache outgrows one Redis.
    3Read replicas and a Redis Cluster.Reads grow with replicas and cache nodes. Writes stay on one primary.Storage passes about 10 TB: after about 7 months here.
    4Shard Postgres by hash(code).183 TB ÷ 10 TB per node is 19 shards. Every redirect reads one shard.Users far from the region wait 100 ms or more for a redirect.
    5Edge redirects: copy hot codes to caches in each region.A redirect costs one round trip to the nearest region.Top of the ladder.

    Demand as in panel B. 10 TB per node is the planning ceiling from R2: 50 GB a day reaches it in 200 days. Lab capacity comes from one laptop; read it as orders of magnitude.

    I start with one Postgres and a stateless service. I add Redis when reads near one node, and shards when storage passes about 10 TB.

    N

    Follow-ups

    what the interviewer asks next
    questionanswerdeeper
    How do you shard?By a hash of the code, because every redirect knows the code. Consistent hashing keeps most keys in place when you add a shard.X2
    The creator clicks the new link at once. Can a replica miss it?Yes, if the read goes to a lagging replica. Read the creator's own links from the primary for a few seconds.X1
    How do you count unique visitors?A HyperLogLog per link and day. Redis documents about 12 KB per key and a standard error of 0.81%.B10
    The analytics team wants clicks by country and hour.Log every click to a stream with its metadata. Aggregate per minute into a columnar store.B7, B12
    Where does the cache live: Redis or the service?Both, for hot keys. Each service keeps the top links for a few seconds; Redis holds the rest.B4
    How do you get fast redirects worldwide?Run redirects at the edge, from a copy of the hot codes. Creates still go to one primary region.B5
    How available must each path be?The redirect more than the create. It keeps working on Redis alone or on a replica alone.O1
    Why not a time-ordered 64-bit ID as the code?It is unique without a database call, but it needs 11 base62 characters. Short codes are the point of the product.X4
    O

    Drill

    predict, then reveal

    0 of 9 known

    1. 100 million new links a day for 10 years. Is a 6-character code long enough?

    2. Why not SELECT the code first and insert only if it is free?

    3. The lab inserted 1 million random 6-character codes. About how many collided, and why?

    4. A server holds a block of 1,000 values and crashes after 300. What happens to the other 700?

    5. 301 or 302 for the redirect?

    6. A bot requests millions of random codes. What reaches Postgres?

    7. Why count clicks in memory and not with UPDATE links SET clicks = clicks + 1?

    8. A user wants the alias "Abc1234". Why refuse it?

    9. How do you shard the links table?

    P

    Numbers to say

    derived and measured
    creates
    100 M a day: 1,157 a second; 3,472 at a 3× peak.
    redirects
    100 per link: 115,741 a second; 347,222 at peak.
    codes
    62⁷ = 3.52 trillion. 10 years of links use 10%.
    storage
    500 B a link: 50 GB a day, 183 TB in 10 years.
    collisions
    1 M random codes: 576 collided at 5 chars, 4 at 6, 0 at 7.
    ranges
    1 database call per 1,000 codes.
    cache
    551 B per cached link; an LRU of 1% of links answered 75%.
    lookup
    Under 1 ms at p50 in Redis or Postgres; 10⁴ to 10⁵ a second per node.

    Postgres 16 and Redis 8 on a 16-thread laptop shared with other jobs, 32 clients. Use the lab numbers as orders of magnitude.