1. Courses
  2. System Design
  3. System Design Case Studies
  4. Stop 2

40 minIntermediate

Case study: a URL shortener

A URL shortener looks like a weekend project: store a long link, give back a short code, redirect. Done properly, it touches almost every idea in the system design track. We will walk through it as a real design review for Mangrove Market, step by step, from requirements to rollout, using the framework from Running a design review.

By the end you can

  • Turn a vague request into requirements and numbers for a read-heavy system
  • Compare four ways to generate short codes and pick one for your numbers
  • Explain why 301 and 302 redirects lead to different products
  • Keep redirects fast with a cache, and count clicks without slowing them down
  • Fill in a failure table and a rollout plan for the design

The everyday picture

Think of the token counter at a busy bank branch. You hand over a long form with your account details, and the clerk gives you a small plastic token with a number on it, say 47. Later, anyone holding token 47 can walk to the counter, and the clerk looks up the form in a second. The token is short because the long form is kept safely behind the counter.

A URL shortener is that token counter. The long link is the form. The short code is the token. Tokens are handed out now and then, so that step may take a moment. Looking them up happens all day and must be instant. Two tokens must never have the same number. And if someone finds token 48 on the floor, they should not be able to guess token 49 and see a stranger's form. Most of this lesson is about those last three sentences.

Step 1: Requirements

Mangrove's engineers build a shortener for two uses. Marketing sends SMS campaigns with offer links. Stores send digital receipts by SMS after a sale, with a link to the receipt page. Long links with tracking parameters do not fit well in an SMS: a plain English SMS holds 160 characters, but an SMS in a language that needs Unicode, such as Bangla, Hindi, Arabic or Thai, holds only 70. A 90-character link can turn one SMS into two, which doubles the cost for every customer.

Functional requirements.

  • FR1: An internal system (the campaign tool, the receipt service behind the stores' tills) can create a short link for a long URL, with an optional expiry date.
  • FR2: Anyone who opens a short link is redirected to the long URL.
  • FR3: The owner can see clicks per day for a link.
  • FR4: An admin can disable a link at once, for example when an offer was sent with the wrong price.

Non-functional requirements.

  • Redirect p99 under 50 ms measured at the API, because it sits in front of every customer's first page view.
  • Redirects 99.95% available. Creating links can be less available (99.9%), because the caller can retry.
  • A link, once created, never points to a different page. Old SMS messages stay in phones for years.
  • Receipt links must not be guessable, because a receipt shows a customer's purchases.
  • Click counts may be a few minutes late, and losing a small share of clicks during an outage is acceptable.

Non-goals. A public shortener for anyone on the internet, custom vanity codes, and real-time dashboards.

Step 2: Numbers

The numbers below use Mangrove Market's volumes and the method from Estimation. About a quarter of the 400,000 checkouts a day across the stores ask for a receipt by SMS; your team would put in its own share.

Output
New links    400,000 checkouts/day x ~25% e-receipt = 100,000 links/day
             + campaigns (one link each)             (a rounding error)
Writes       100,000 / 86,400 s                    = ~1.2 per second
Stored       100,000 x 365 x 3 years               = ~110 million links

Reads        100 reads per write (typical)         = 10 million redirects/day
             10,000,000 / 86,400 s                 = ~116 per second average
Peak         evening x5, campaign blast on top     = plan for ~2,000 per second

Link row     code + URL (~100 B) + columns + index = ~350 B
Link table   110 million x 350 B                   = ~40 GB in 3 years
Clicks       10 million/day x ~100 B               = ~1 GB/day, keep 90 days raw
Hot cache    1 million most-used links x ~200 B    = ~200 MB of Redis

Read what the numbers say. Writes are tiny: even 20 inserts per second in a busy hour are nothing for one Postgres server. The link table is about 40 GB in three years, which fits on one server easily, so sharding is not needed (Sharding). The hard parts are elsewhere: 2,000 redirects per second with a 50 ms p99, a campaign spike that arrives in minutes, and 1 GB of click data per day that must not slow the redirect.

Quick check

The link table will be about 40 GB after three years. What should the design do about that?

Step 3: API

Three endpoints carry the product. Creating and stats sit on the internal API with API-key authentication. The redirect lives on its own short domain, such as s.example.com, with no cookies and no login.

Output
POST /v1/links                     (API key)
  { "longUrl": "https://receipts.example.com/r/D027-000981",
    "externalRef": "receipt-D027-000981", "isPrivate": true,
    "expiresAt": "2027-10-07T00:00:00Z" }
  201 { "code": "rnxhSqFTlf", "shortUrl": "https://s.example.com/rnxhSqFTlf" }
  400 not https, or not one of our domains     429 too many requests

GET /{code}                        (public, the hot path)
  302 Location: <longUrl>      404 unknown code      410 expired or disabled

GET /v1/links/{code}/stats?from=2026-10-01&to=2026-10-07     (API key)
  200 { "days": [ { "day": "2026-10-07", "clicks": 1840 }, ... ] }

POST /v1/links/{code}/disable      (admin)
  204

Notice externalRef. If the receipt service times out and retries, a unique key on (owner, externalRef) makes the retry return the first link instead of a second one. This is the idempotency idea from APIs done right, using a key the caller already has.

Step 4: Data model

Three tables. link holds one row per short code and is the . click holds one row per redirect, is append-only and is by month, so old months can be dropped in a second instead of deleted row by row (Partitioning). click_daily holds one row per link per day, so the stats endpoint reads a few dozen small rows instead of counting millions.

Two details matter. The code column must compare case-sensitively, because aZ3k9Qx and AZ3k9Qx are different links. A Postgres varchar with a normal unique index does that by default. And when a link has expired for a while, we clear its URL but keep the row, so the code can never be given to a different page.

Step 5: The big picture

Follow one click, top to bottom. The customer taps the link. Nginx ends TLS and limits requests per IP. One of three identical API copies checks Redis for link:aZ3k9Qx and answers with a 302 on a hit. On a miss, it reads one row from Postgres by its unique index and fills Redis. Either way, it adds a click event to a Redis Stream without waiting.

Customer phone Campaign tool GET /aZ3k9Qx 302 POST /v1/links Edge (Nginx) TLS, rate limit per IP and per API key NestJS API x3 stateless copies redirect + create 1. GET key 2. on a miss or create 3. XADD Cache Redis LRU Postgres link, click Stream Redis AOF batch 500 Worker click writer insert + count user waits in the background
The whole system. The customer's click (blue) only waits for the edge, the API and usually Redis. Postgres is read only on a cache miss. Click events go to a Redis Stream without waiting, and a worker writes them to Postgres in batches of up to 500.

Deep dive 1: Generating the short code

This is the classic interview question, and the answer depends on the numbers. A code made of characters (0-9, a-z, A-Z) is URL-safe and short. Seven characters give 627, about 3.5 trillion codes. That is about 32,000 times more than the 110 million links we expect. There are four common ways to choose the characters.

A. A counter, written in base62. Take the next number from a Postgres sequence and convert it. Link number 125,000,001 becomes 8sud3. Codes are as short as possible and never collide. The problem is that the next code is easy to guess: 8sud4, 8sud5. Anyone can walk through all your receipts. Libraries such as Sqids scramble the number so it looks random, but they are not designed to be secret.

B. A hash of the URL. Take SHA-256 of the long URL and keep the first 7 base62 characters. The same URL always gets the same code. But two different URLs can produce the same 7 characters, called a , so you still need a check and a retry. Two owners who shorten the same URL also share one code, so their stats and takedowns mix.

C. Random, with a retry. Draw 7 random characters from a cryptographic random generator and insert. The unique index on code rejects the rare clash, and you try again. With 110 million links stored, a new random code clashes about 1 time in 32,000, so the retry almost never runs. Codes cannot be guessed, and no central counter is needed.

D. A Snowflake-style ID. A packs the time in milliseconds, a machine number and a per-millisecond counter into 64 bits. Each server creates unique IDs without asking the database. In base62 that is 10 to 11 characters, and it leaks the creation time. It is the right tool when you write tens of thousands of rows per second across many servers. We write about one per second.

Quick check

Why is a counter written in base62 a poor choice for receipt links?

A. Counter + base62 125000001 to base62 8sud3 + shortest, no clash - next code guessable B. Hash of the URL long URL SHA-256, cut 7 u7RqdgN + same URL, same code - clashes need a check C. Random + retry our choice 7 x randomInt Qu9WIao unique insert + unguessable, no hub - rare clash: retry D. Snowflake ID time|node|seq 64-bit number r3ao1VRUP5 + no DB call, sorted - 10-11 chars, clocks
Four ways to make a code. A counter is shortest but guessable. A hash dedupes URLs but still needs a collision check. Random codes with a retry on the unique index are unguessable and need no coordinator, which fits our numbers. Snowflake IDs suit very high write rates across many servers.

How long must the code be?

Guessing matters more than collisions here. Say an attacker tries 1 million random 7-character codes. Each guess hits a real link with a chance of 110 million in 3.5 trillion, so 1 million guesses find about 31 real links. For offers sent to thousands of people anyway, that is fine. For receipts, it is not. With 10 characters (6210 is about 8 × 1017), the same million guesses find about 0.0001 links. So the design uses 7 characters for campaign links and 10 for private links, and rate-limits guessing at the edge. Three extra characters in an SMS is a cheap price for privacy.

Deep dive 2: Fast redirects, and 301 or 302

The redirect path uses cache-aside from Caching, with three additions. First, warm on create: the create endpoint writes the new link into Redis right away, because a receipt link is usually opened within minutes. Second, cache the misses: an unknown code is stored for 5 minutes as a marker, so repeated lookups of the same bad code, such as a mistyped link pasted into a group chat or a bot retrying it, are answered by Redis, not Postgres. This is called . Third, never cache past expiry: the TTL is one day plus random jitter, but never longer than the time until expires_at.

If the cache Redis is slow or down, the code treats it as a miss within 50 ms and reads Postgres. A lookup by unique index takes well under a millisecond inside the database, so Postgres can carry the full 2,000 per second for a while. That is a good reason to keep the cache as an optimisation, not as the only copy.

Now the status code. means "moved permanently". Browsers remember it and may go straight to the long URL next time without asking you. means "found, for now", so the browser asks you again on the next click. The diagram shows what this means for the product.

Phone Short link Shop site 301 Moved Permanently click 1 301 + Location GET page click 2 from cache We never see click 2 302 Found click 1 302 + Location GET page click 2 Every click reaches us
301 against 302. With a 301, the phone remembers the redirect, so later clicks go straight to the shop: we cannot count them, and disabling a wrong link does nothing for people who already clicked. With a 302, every click comes back, so counts and takedowns work, at the price of serving every click.

For this product, 302 wins. Marketing wants click counts, and FR4 says a wrong link must be fixable. The cost is that every click reaches your servers, which the numbers already allow. The code adds Cache-Control: private, max-age=90, so a customer who taps twice within 90 seconds is served from the phone. A shortener that only cares about speed and has no analytics might choose 301 instead.

Quick check

A team builds a shortener for a public blog. It needs no click counts and will never disable a link. Which redirect fits best?

Deep dive 3: Counting clicks without slowing redirects

Writing a row to Postgres on every click would put 2,000 inserts per second on the primary at peak, inside the customer's wait. Instead, the API adds one small event to a with XADD and does not wait for the answer. This is the queue pattern from Queues and jobs, with a log-style tool.

A worker reads up to 500 events at a time as part of a . It inserts them in one statement and only then acknowledges them with XACK. If it crashes between the insert and the ack, the entries stay pending. On restart, it reads its own pending entries first. Each click's primary key is the stream entry id, so the second insert skips rows that already exist. The same statement adds only the newly inserted rows to click_daily, so a replay never counts a click twice. That is an consumer, exactly as in Outbox and sagas.

The stream is capped at about 2 million entries with MAXLEN ~, a few hundred MB of memory: roughly 5 hours of clicks at the average rate, or 17 minutes at the peak. If the worker is down for longer, the oldest unread clicks are trimmed and lost. The requirements allow that, so we accept it on purpose, with an alert on the stream's lag. Kafka would keep events for days, but it is a much heavier system to run.

Deep dive 4: Abuse, expiry and cleanup

Phishing. A shortener that redirects to any URL is an : scammers can make a fake login page for a mobile wallet (M-Pesa, UPI, bKash or GCash) look like a link from your brand. Because our callers are internal, the create endpoint only accepts https links to an allow-list of our own domains. A public shortener would need URL scanning and abuse reports instead.

Guessing and floods. The edge limits redirects per IP and creates per API key (Rate limiting). Be generous with per-IP limits: mobile operators often put many users behind one public IP with , so a strict limit would block real customers during a campaign. Each new guess still costs one indexed read in Postgres, well under a millisecond, so the per-IP limit is what keeps a scanner cheap; negative caching absorbs codes that are tried again and again.

nginx
# Edge limits on the short domain (inside the http block)
limit_req_zone $binary_remote_addr zone=redirect_ip:10m rate=30r/s;

server {
    listen 443 ssl;                          # certificate lines as in the Networking lesson
    server_name s.example.com;               # no cookies are ever set here

    location / {
        limit_req zone=redirect_ip burst=60 nodelay;   # generous: CGNAT
        limit_req_status 429;
        proxy_pass http://shortener_api;      # upstream block as in the Load balancers lesson
    }
}

Expiry. The API checks expires_at on every lookup and answers 410 Gone. A friendlier product could redirect to a "This offer has ended" page instead, which is a product choice for the review. A nightly job clears the URL of links that expired more than 30 days ago, in batches of 5,000 rows (Safe migrations). Raw clicks older than 90 days go by dropping a whole month partition, while click_daily keeps the totals forever at a tiny cost.

Step 6: Failure modes

Cover each box in the diagram and ask what the customer sees.

What failsWhat users seeWhat the design does
Cache Redis downNothing; redirects a few ms slower50 ms command timeout, no offline queue; every lookup reads Postgres by unique index; alert
Stream Redis downNothingXADD fails silently; a "clicks dropped" metric counts the loss; alert
Click worker crashesNothing; stats latePending entries re-read on restart; inserts dedupe on the stream id; lag alert
Postgres primary downHot links still work; new and cold links failCache serves most clicks; failover to a replica (Replication); creates return 503 and callers retry
Create times out, caller retriesOne link, not twoUnique (owner_id, external_ref) returns the first link
Random code already takenNothingUnique index rejects it (P2002); draw a new code, up to 5 tries
Wrong URL sent to 200,000 peopleFixed within seconds302 means clicks come back: disable or correct the link and delete its cache key
Campaign blast, 10x trafficSlightly slower at worstWarm cache on create; stateless API scales out; per-IP limits generous for CGNAT
Someone scans codes429 for the scannerRandom codes, 10 chars for private links, negative cache, edge rate limit

Step 7: Trade-offs we accepted

  • 302 instead of 301. Every click costs us a request. In return we get counts and the power to fix a wrong link.
  • Random codes instead of a counter. Codes are 2 characters longer, and a rare retry runs. In return nobody can walk through the receipts.
  • Redis Stream instead of Kafka. A long worker outage can lose old clicks. In return we run one familiar tool, and analytics can never slow a redirect.
  • One Postgres, no sharding. About 40 GB in three years fits easily. We revisit if links pass roughly 1 billion or writes pass a few thousand per second.
  • Click counts a few minutes late. Batching saves thousands of writes per second; the stats screen says "updated every few minutes".

Step 8: Rollout, monitoring and cost

Start with receipts at 5 pilot stores, behind a feature flag in the receipt service (Shipping safely), and compare SMS parts per receipt before and after. Add campaigns after two weeks. Register the short domain early and keep it only for redirects. Some SMS filters treat unknown link domains with suspicion, so test delivery through your SMS gateway on each mobile operator before a big campaign.

Watch five numbers on one dashboard (Observability): redirect p99, cache hit ratio (expect above 95%), the 404 rate (a sudden rise means scanning), the stream lag, and 5xx errors. The redirect SLOs come straight from the requirements: 99.95% of redirects succeed, and 99% finish in under 50 ms. The monthly cost is small: a Postgres with a replica, two small Redis servers, and a few API copies.

Build it

We will build the code generator, the schema, the create and redirect endpoints with a Redis cache, the click worker, and the cleanup jobs. NestJS 10, Prisma 6, ioredis 5 and Postgres 16. You can practise the base62 helpers and the code-length arithmetic in your browser, with no setup, in the Practice section below.

  1. Write the base62 helpers

    toBase62 is here to show option A, and randomCode is option C, the one we use. Use crypto.randomInt, never Math.random, because codes for private links must be unpredictable.

    TypeScript
    // base62.ts
    import { randomInt } from 'node:crypto';
    
    const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    const BASE = 62n;
    
    export function toBase62(n: bigint): string {
      if (n < 0n) throw new RangeError('negative');
      if (n === 0n) return '0';
      let out = '';
      while (n > 0n) {
        out = ALPHABET[Number(n % BASE)] + out;     // the remainder is the last digit
        n /= BASE;                                   // bigint division drops the remainder
      }
      return out;
    }
    
    export function fromBase62(s: string): bigint {
      let n = 0n;
      for (const ch of s) {
        const d = ALPHABET.indexOf(ch);
        if (d < 0) throw new RangeError(`not base62: ${ch}`);
        n = n * BASE + BigInt(d);
      }
      return n;
    }
    
    // One unpredictable, unbiased character at a time. 7 chars = 62^7, about 3.5 trillion codes.
    export function randomCode(length = 7): string {
      let code = '';
      for (let i = 0; i < length; i++) code += ALPHABET[randomInt(62)];   // crypto, not Math.random
      return code;
    }
    TypeScript
    // try-base62.ts: run with npx tsx try-base62.ts
    import { fromBase62, randomCode, toBase62 } from './base62';
    
    console.log(toBase62(125_000_001n), toBase62(125_000_002n));  // a counter: the next code is easy to guess
    console.log(fromBase62('8sud3'));                             // and back again
    console.log(randomCode(), randomCode(10));                    // random: no pattern to follow
    console.log(toBase62(2n ** 63n - 1n).length);                 // the biggest bigint id needs 11 chars
    Output
    8sud3 8sud4
    125000001n
    Qu9WIao 7GOfbrNhBr
    11

    Your random codes will differ on every run, which is the point.

  2. Define the Prisma models

    longUrl is optional because cleanup clears it while the row stays. The composite unique on ownerId and externalRef is what makes retried creates safe. By default Postgres treats NULLs as distinct in a unique index, so links without an externalRef never clash.

    Prisma
    // prisma/schema.prisma (models only)
    model Link {
      id          BigInt    @id @default(autoincrement())
      code        String    @unique @db.VarChar(16)               // case-sensitive: aZ3k9Qx != AZ3k9Qx
      longUrl     String?   @map("long_url")                      // NULL after purge; the code stays taken
      ownerId     BigInt    @map("owner_id")                      // the calling system (API key)
      externalRef String?   @map("external_ref")                  // e.g. a receipt id
      createdAt   DateTime  @default(now()) @map("created_at") @db.Timestamptz
      expiresAt   DateTime? @map("expires_at") @db.Timestamptz    // NULL = never expires
      disabledAt  DateTime? @map("disabled_at") @db.Timestamptz   // abuse takedown
    
      @@unique([ownerId, externalRef])                             // a retried create finds the first link
      @@map("link")
    }
    
    model Click {
      eventId   String   @map("event_id")                         // Redis stream entry id
      clickedAt DateTime @map("clicked_at") @db.Timestamptz
      code      String   @db.VarChar(16)
      userAgent String?  @map("user_agent")
    
      @@id([eventId, clickedAt])                                   // must include the partition key
      @@index([code, clickedAt])
      @@map("click")                                               // partitioned by month: raw SQL below
    }
    
    model ClickDaily {
      code   String   @db.VarChar(16)
      day    DateTime @db.Date                                     // calendar day in the reporting zone
      clicks BigInt
    
      @@id([code, day])
      @@map("click_daily")
    }
  3. Edit the migration for what Prisma cannot express

    Run npx prisma migrate dev --create-only --name links and open the generated file. Change the end of its CREATE TABLE "click" statement from ); to ) PARTITION BY RANGE ("clicked_at");, keep everything else Prisma wrote, and add the lines below at the end. Create future months with a scheduled job, as in Partitioning.

    SQL
    -- (end of Prisma's generated CREATE TABLE "click" ...)  ) PARTITION BY RANGE ("clicked_at");
    
    -- Monthly partitions plus a safety net. UTC months are fine for raw clicks:
    -- they are only kept 90 days, and click_daily holds the local-day totals.
    CREATE TABLE click_2026_10 PARTITION OF click
      FOR VALUES FROM ('2026-10-01 00:00+00') TO ('2026-11-01 00:00+00');
    CREATE TABLE click_2026_11 PARTITION OF click
      FOR VALUES FROM ('2026-11-01 00:00+00') TO ('2026-12-01 00:00+00');
    CREATE TABLE click_default PARTITION OF click DEFAULT;   -- alert if it ever gets rows
    
    -- Prisma cannot describe these two, and leaves them alone once they exist
    ALTER TABLE link ADD CONSTRAINT link_long_url_len CHECK (length(long_url) <= 2048);
    CREATE INDEX link_purge_idx ON link (expires_at) WHERE long_url IS NOT NULL;
  4. Validate input and connect to two Redis servers

    The DTO rejects anything that is not an https URL, which blocks javascript: and data: links. The cache Redis may evict keys; the stream Redis must not, so they are separate servers with different settings (Caching, Queues and jobs).

    TypeScript
    // create-link.dto.ts
    import { IsBoolean, IsISO8601, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
    
    export class CreateLinkDto {
      @IsUrl({ protocols: ['https'], require_protocol: true })   // rejects javascript:, data:, http:
      @MaxLength(2048)
      longUrl!: string;
    
      @IsOptional() @IsBoolean()
      isPrivate?: boolean;             // receipts and other personal pages get a 10-character code
    
      @IsOptional() @IsString() @MaxLength(100)
      externalRef?: string;            // e.g. "receipt-D027-000981": same ref, same link
    
      @IsOptional() @IsISO8601()
      expiresAt?: string;
    }
    TypeScript
    // redis.providers.ts: two Redis servers with different jobs
    import { Logger, Provider } from '@nestjs/common';
    import Redis, { RedisOptions } from 'ioredis';
    
    export const CACHE = 'CACHE_REDIS';   // allkeys-lru: losing a key only costs one DB read
    export const QUEUE = 'QUEUE_REDIS';   // noeviction + AOF: clicks must not be evicted
    
    const log = new Logger('Redis');
    function connect(url: string, opts: RedisOptions): Redis {
      const r = new Redis(url, {
        enableOfflineQueue: false,        // Redis down: fail at once instead of piling up commands
        maxRetriesPerRequest: 1,
        ...opts,
      });
      r.on('error', (e) => log.warn(`${url}: ${e.message}`));   // keep running; it reconnects by itself
      return r;
    }
    
    export const redisProviders: Provider[] = [
      { provide: CACHE, useFactory: () => connect(process.env.CACHE_REDIS_URL!, { commandTimeout: 50 }) },
      { provide: QUEUE, useFactory: () => connect(process.env.QUEUE_REDIS_URL!, { commandTimeout: 200 }) },
    ];
  5. Create links with a retry on clashes

    Prisma raises P2002 when a unique constraint fails. Two different constraints can cause it here: the random code was taken, or the same externalRef was sent again. The code checks for the second case first and returns the existing link.

    TypeScript
    // links.service.ts (part 1: create)
    import { BadRequestException, GoneException, Inject, Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
    import { Link, Prisma, PrismaClient } from '@prisma/client';
    import Redis from 'ioredis';
    import { randomCode } from './base62';
    import { CreateLinkDto } from './create-link.dto';
    import { CACHE } from './redis.providers';
    
    export class PrismaService extends PrismaClient {}
    const ALLOWED_HOSTS = new Set(['shop.example.com', 'receipts.example.com']);
    const short = (code: string) => ({ code, shortUrl: `https://s.example.com/${code}` });
    
    @Injectable()
    export class LinksService {
      constructor(private readonly prisma: PrismaService, @Inject(CACHE) private readonly cache: Redis) {}
    
      async create(ownerId: bigint, dto: CreateLinkDto) {
        const url = new URL(dto.longUrl);
        if (!ALLOWED_HOSTS.has(url.hostname)) throw new BadRequestException('Only links to our own sites');
        const expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null;
        for (let attempt = 0; attempt < 5; attempt++) {
          try {
            const link = await this.prisma.link.create({ data: {
              code: randomCode(dto.isPrivate ? 10 : 7), longUrl: url.href, ownerId, externalRef: dto.externalRef, expiresAt,
            } });
            await this.warm(link);                     // the first click, seconds later, is a cache hit
            return short(link.code);
          } catch (e) {
            if (!(e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002')) throw e;
            if (dto.externalRef) {                     // P2002 on externalRef: a retry of a create that worked
              const prev = await this.prisma.link.findUnique({
                where: { ownerId_externalRef: { ownerId, externalRef: dto.externalRef } },
              });
              if (prev) return short(prev.code);
            }
            // otherwise the random code was already taken: loop and draw a new one
          }
        }
        throw new ServiceUnavailableException('Could not allocate a code, try again');
      }
    TypeScript
    // links.controller.ts: internal API, one API key per calling system
    import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
    import { ApiKeyGuard, ApiKeyRequest } from './api-key.guard';   // hashes the key, sets req.client
    import { CreateLinkDto } from './create-link.dto';
    import { LinksService } from './links.service';
    
    @Controller('v1/links')
    @UseGuards(ApiKeyGuard)
    export class LinksController {
      constructor(private readonly links: LinksService) {}
    
      @Post()
      create(@Req() req: ApiKeyRequest, @Body() dto: CreateLinkDto) {
        return this.links.create(req.client.id, dto);       // 201 with { code, shortUrl }
      }
    }
  6. Resolve codes with cache-aside, negative caching and expiry

    Every Redis call has a .catch, so a Redis problem becomes a cache miss, never an error page. disable deletes the key, so a takedown works on the next click.

    TypeScript
    // links.service.ts (part 2: resolve, inside LinksService)
      async resolve(code: string): Promise<string> {
        const key = `link:${code}`;
        const hit = await this.cache.get(key).catch(() => null);      // Redis down = a miss, not an error
        if (hit === '!') throw new NotFoundException();               // cached "no such code"
        if (hit) return hit;
    
        const link = await this.prisma.link.findUnique({ where: { code } });   // unique index lookup
        if (!link) {
          await this.cache.set(key, '!', 'EX', 300).catch(() => null);         // scanners stop at Redis
          throw new NotFoundException();
        }
        if (!link.longUrl || link.disabledAt || (link.expiresAt && link.expiresAt <= new Date())) {
          throw new GoneException();                                  // 410: existed once, gone now
        }
        await this.warm(link);
        return link.longUrl;
      }
    
      async disable(code: string) {                                   // abuse takedown
        await this.prisma.link.update({ where: { code }, data: { disabledAt: new Date() } });
        await this.cache.del(`link:${code}`);                         // effective on the next click
      }
    
      private async warm(link: Link) {
        let ttl = 86_400 + Math.floor(Math.random() * 3600);          // 1 day + jitter (see Caching)
        if (link.expiresAt) ttl = Math.min(ttl, Math.floor((link.expiresAt.getTime() - Date.now()) / 1000));
        if (link.longUrl && ttl > 0) await this.cache.set(`link:${link.code}`, link.longUrl, 'EX', ttl).catch(() => null);
      }
    }
  7. Write the redirect endpoint

    The shape check runs before any lookup, so junk paths such as /wp-login.php cost nothing. The click is recorded with a promise that nobody awaits, so the customer never waits for analytics.

    TypeScript
    // redirect.controller.ts: the only controller on the short domain
    import { Controller, Get, Headers, Inject, NotFoundException, Param, Res } from '@nestjs/common';
    import { Response } from 'express';
    import Redis from 'ioredis';
    import { LinksService } from './links.service';
    import { QUEUE } from './redis.providers';
    
    const CODE = /^[0-9A-Za-z]{7,10}$/;            // 7 for offers, 10 for private links
    
    @Controller()
    export class RedirectController {
      constructor(private readonly links: LinksService, @Inject(QUEUE) private readonly queue: Redis) {}
    
      @Get(':code')
      async go(@Param('code') code: string, @Headers('user-agent') ua = '', @Res() res: Response) {
        if (!CODE.test(code)) throw new NotFoundException();      // cheap reject: no Redis, no DB
        const url = await this.links.resolve(code);               // throws 404 or 410
    
        // Record the click without awaiting it. Analytics must never slow down or break a redirect.
        this.queue
          .xadd('clicks', 'MAXLEN', '~', '2000000', '*', 'code', code, 'ts', String(Date.now()), 'ua', ua.slice(0, 200))
          .catch(() => { /* clicksDropped.inc(): a metric, not an error for the user */ });
    
        res.setHeader('Cache-Control', 'private, max-age=90');    // a double tap within 90 s stays on the phone
        res.redirect(302, url);                                    // 302: later clicks still come to us
      }
    }
  8. Write the click worker

    Run it as its own process, not inside the API. The CTE inserts the raw clicks and counts only the rows that were really inserted, so a replay after a crash cannot double the daily numbers. If a worker is gone for good, another copy can take over its pending entries with XAUTOCLAIM.

    TypeScript
    // click-worker.ts: its own process; give each copy a stable WORKER_NAME
    import Redis from 'ioredis';
    import { PrismaClient } from '@prisma/client';
    
    const redis = new Redis(process.env.QUEUE_REDIS_URL!);
    const prisma = new PrismaClient();
    const GROUP = 'click-writers', ME = process.env.WORKER_NAME ?? 'clicks-1';
    const REPORT_TZ = process.env.REPORT_TZ ?? 'UTC';   // the business's day, e.g. 'Asia/Jakarta'
    type Entry = [id: string, fields: string[] | null];
    
    async function main() {
      await redis.xgroup('CREATE', 'clicks', GROUP, '0', 'MKSTREAM')
        .catch((e) => { if (!String(e).includes('BUSYGROUP')) throw e; });   // group already exists
      let from = '0';                     // '0' = my unacked entries from before a crash; '>' = new ones
      for (;;) {
        const res = (await redis.xreadgroup('GROUP', GROUP, ME, 'COUNT', 500, 'BLOCK', 5000,
          'STREAMS', 'clicks', from)) as [string, Entry[]][] | null;
        const entries = res?.[0]?.[1] ?? [];
        if (entries.length === 0) { from = '>'; continue; }
        const rows = entries.filter((e): e is [string, string[]] => e[1] !== null).map(([id, kv]) => {
          const f = Object.fromEntries(kv.flatMap((v, i) => (i % 2 ? [] : [[v, kv[i + 1]]])));
          return { id, code: f.code ?? '', at: new Date(Number(f.ts)).toISOString(), ua: f.ua ?? '' };
        });
        // One statement: insert the raw clicks, then count ONLY the rows really inserted.
        // A replayed entry hits ON CONFLICT, is not RETURNed, and is never counted twice.
        await prisma.$executeRaw`
          WITH ins AS (
            INSERT INTO click (event_id, clicked_at, code, user_agent)
            SELECT id, at, code, NULLIF(ua, '')
            FROM unnest(${rows.map((r) => r.id)}::text[], ${rows.map((r) => r.at)}::timestamptz[],
                        ${rows.map((r) => r.code)}::text[], ${rows.map((r) => r.ua)}::text[]) AS t(id, at, code, ua)
            ON CONFLICT DO NOTHING
            RETURNING code, clicked_at)
          INSERT INTO click_daily (code, day, clicks)
          SELECT code, (clicked_at AT TIME ZONE ${REPORT_TZ}::text)::date, count(*) FROM ins GROUP BY 1, 2
          ON CONFLICT (code, day) DO UPDATE SET clicks = click_daily.clicks + EXCLUDED.clicks`;
        await redis.xack('clicks', GROUP, ...entries.map(([id]) => id));   // ack only after the commit
      }
    }
    main();
  9. Clean up, and read the stats

    Run the purge in a loop until it updates 0 rows. Dropping a partition removes a month of clicks almost instantly and leaves nothing for vacuum (Postgres ops).

    SQL
    -- Nightly, in a loop until it updates 0 rows: forget the URL of links that
    -- expired over 30 days ago, 5,000 rows per transaction. The code row stays,
    -- so the code is never handed out again to a different page.
    UPDATE link SET long_url = NULL
    WHERE id IN (
      SELECT id FROM link
      WHERE long_url IS NOT NULL
        AND expires_at < now() - interval '30 days'
      ORDER BY expires_at
      LIMIT 5000
    );
    
    -- Monthly: raw clicks older than 90 days go; click_daily keeps the totals.
    SET lock_timeout = '3s';                            -- give up rather than block redirects
    ALTER TABLE click DETACH PARTITION click_2026_07;   -- brief strong lock: run at night
    DROP TABLE click_2026_07;
    
    -- The stats endpoint reads only the small daily table
    SELECT day, clicks
    FROM click_daily
    WHERE code = 'aZ3k9Qx'
      AND day >= current_date - 30                     -- the last 30 days
    ORDER BY day;
  10. Try it

    With the API on port 3000, create a link and follow it. The output below is from a test run.

    Terminal
    curl -s -X POST localhost:3000/v1/links -H 'content-type: application/json' \
      -H 'x-api-key: dev-key' \
      -d '{"longUrl":"https://shop.example.com/offers/festival?utm_source=sms","expiresAt":"2026-10-20T18:00:00Z"}'
    
    curl -si localhost:3000/SLz0YAz | head -4
    redis-cli -p 6379 TTL link:SLz0YAz        # cache warmed on create
    Output
    {"code":"SLz0YAz","shortUrl":"https://s.example.com/SLz0YAz"}
    HTTP/1.1 302 Found
    X-Powered-By: Express
    Cache-Control: private, max-age=90
    Location: https://shop.example.com/offers/festival?utm_source=sms
    (integer) 86752

    On the stream Redis, XINFO GROUPS clicks shows a lag field: the number of clicks not yet read by the worker. That is the number to alert on.

Bring it to your team

Use these when your team builds anything that hands out codes, tokens or links.

  • "If someone changes one character of a link, can they see somebody else's data?"
  • "Do we need click counts and takedowns? Then why would this be a 301?"
  • "What happens to redirects when Redis is down? Show me the timeout."
  • "If the click worker is down for a day, what do we lose, and who gets alerted?"
  • "Can an expired code ever be given to a new page?"
  • "Which domains can a link point to, and who decides?"

Red flags

  • Sequential codes for receipts, invoices or anything with personal data.
  • An insert into Postgres inside every redirect request.
  • A shortener that accepts any URL from anyone, with no abuse plan.
  • Sharding proposed for a table that will be 40 GB in three years.
  • Strict per-IP limits on a consumer link that mobile users open behind CGNAT.

Practice

Encode and decode base62

Write toBase62(n), which turns a whole number of 0 or more into a base62 string, and fromBase62(s), which turns it back. Use the alphabet given: digits first, then small letters, then capitals. Work like long division: the remainder after dividing by 62 is the last character. The lesson's counter example, 125,000,001, must become 8sud3. fromBase62 should throw an error for any character outside the alphabet.

JavaScript exerciseRuns in your browser with tests
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

function toBase62(n) {
  // repeat: take n % 62 as the next character from the right, then divide n by 62
  return '';
}

function fromBase62(s) {
  // for each character: n = n * 62 + its position in ALPHABET
  return 0;
}
Ctrl + Enter runs
Show a solution
JavaScript
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

function toBase62(n) {
  if (n === 0) return '0';
  let out = '';
  while (n > 0) {
    out = ALPHABET[n % 62] + out;   // the remainder is the last digit
    n = Math.floor(n / 62);         // drop that digit
  }
  return out;
}

function fromBase62(s) {
  let n = 0;
  for (const ch of s) {
    const d = ALPHABET.indexOf(ch);
    if (d < 0) throw new RangeError(`not base62: ${ch}`);
    n = n * 62 + d;
  }
  return n;
}
Pass the exercise to finish this task.

Pick a code length that resists guessing

Each random guess hits a real link with a chance of links / 62 ** length. Write expectedHits(links, length, guesses), which returns how many real links an attacker finds on average. Then write shortestSafeLength(links, guesses, maxHits), which returns the smallest code length (start at 1) for which the expected hits are at most maxHits. With the lesson's 110 million links and a million guesses, 7 characters give about 31 hits.

JavaScript exerciseRuns in your browser with tests
function expectedHits(links, length, guesses) {
  // chance per guess, times the number of guesses
}

function shortestSafeLength(links, guesses, maxHits) {
  // try length 1, 2, 3 ... and return the first one that is safe enough
}
Ctrl + Enter runs
Show a solution
JavaScript
function expectedHits(links, length, guesses) {
  return guesses * (links / 62 ** length);   // 62 ** 7 is about 3.5 trillion
}

function shortestSafeLength(links, guesses, maxHits) {
  let length = 1;
  while (expectedHits(links, length, guesses) > maxHits) length++;
  return length;
}
Pass the exercise to finish this task.

Check yourself

Marketing sent an SMS with the wrong offer link to 200,000 customers. Which design choice lets the team fix it for customers who have not clicked yet, and also for those who clicked an hour ago?

Receipt links use 7-character random codes, and 110 million links exist. An attacker tries 1 million random codes. Roughly how many real receipts can they find?

The click worker inserted a batch of 500 clicks, then crashed before XACK. What happens after it restarts?

Flashcards

Open my review

Key takeaways

  • Start with numbers: a shortener is tiny on writes and heavy on reads, so the work goes into the redirect path, not into sharding.
  • Random codes with a unique index and a retry are unguessable and simple. Make private codes longer, because guesses add up.
  • 302 keeps every click coming back, which makes counts and takedowns possible. 301 trades that away for speed.
  • Cache-aside with warm-on-create, negative caching and expiry-aware TTLs keeps Postgres quiet, and a Redis outage only costs a little speed.
  • Record clicks asynchronously, insert them idempotently in batches, and accept, in writing, what an outage may lose.

Sources

Episode transcriptToken 47 and the receipt next door · about 13 minutes · tap any line to play from there
  1. NadiaToday we do something different, Imran. We run a whole design review, start to finish. The design is a link shortener.
  2. ImranA link shortener? That's a weekend project. Store a long link, hand back a short code, redirect. Done.
  3. NadiaThat's what everyone says on Friday. By Monday it has touched caching, IDs, security, queues and rollouts. Almost every idea in the system design track.
  4. NadiaThe lesson's picture is the token counter at a busy bank branch. You hand over a long form, and the clerk gives you a small plastic token. Say, number forty-seven.
  5. ImranAnd later anyone holding token forty-seven walks up, and the clerk finds the form in a second.
  6. NadiaThe token is short because the long form is kept safely behind the counter. Handing out tokens happens now and then. Looking them up happens all day and must be instant. Two tokens must never share a number.
  7. NadiaAnd if someone finds token forty-eight on the floor, they shouldn't be able to guess forty-nine and read a stranger's form. Most of the lesson is about those last ideas.
  8. ImranOK. Step one of the review, requirements. Why would we even build this?
  9. NadiaTwo uses. Marketing sends SMS campaigns with offer links. And Mangrove's stores send digital receipts by SMS after a sale, with a link to the receipt page.
  10. ImranAnd long links don't fit.
  11. NadiaA plain English SMS holds a hundred and sixty characters. In a language that needs Unicode, like Bangla, Hindi or Arabic, it holds only seventy. A ninety-character link can turn one SMS into two, which doubles the cost for every customer.
  12. ImranSo the features: internal systems create a short link, anyone who opens it is redirected, owners see clicks per day, and an admin can disable a link at once.
  13. NadiaGood. And the quality targets. Redirect p ninety-nine under fifty milliseconds. Redirects ninety-nine point nine five percent available. A link never points to a different page, because old SMS messages stay in phones for years.
  14. NadiaReceipt links must not be guessable. And click counts may be a few minutes late, and losing a small share of clicks during an outage is acceptable.
  15. ImranStep two, the numbers. Our own volumes this time?
  16. NadiaYes. Our stores ring up about four hundred thousand checkouts a day, and about a quarter ask for a receipt by SMS. That's a hundred thousand new links a day. That's about one point two writes a second. Over three years, about a hundred and ten million links.
  17. NadiaReads are about a hundred per write, so ten million redirects a day, about a hundred and sixteen a second on average. With the evening peak and a campaign blast, plan for about two thousand a second.
  18. ImranA hundred and ten million rows. That sounds like we'll need to shard.
  19. NadiaThat's the reflex, and the numbers say no. At about three hundred and fifty bytes a row, the link table is about forty gigabytes in three years. That fits easily on one Postgres server.
  20. ImranSo the hard parts are somewhere else.
  21. NadiaTwo thousand redirects a second at a fifty millisecond p ninety-nine, a campaign spike that arrives in minutes, and about a gigabyte of click data a day that must never slow a redirect.
  22. ImranThe API is small, I assume.
  23. NadiaCreate, redirect, stats, disable. One detail I love: the create call can carry an external reference, like the receipt number. A unique key on owner and reference means a retried create returns the first link, not a second one.
  24. NadiaNow the big picture. If you can see the screen, the first diagram follows one click. The customer's phone hits Nginx, which ends TLS and limits requests per IP. One of three identical API copies checks Redis first, and answers with a redirect on a hit.
  25. ImranAnd on a miss, it reads one row from Postgres and fills Redis.
  26. NadiaYes. Either way, it drops a click event onto a Redis Stream without waiting, and a separate worker writes clicks to Postgres in batches of up to five hundred. The customer only waits for the edge, the API and usually Redis.
  27. ImranNow the classic interview question. How do we make the short code?
  28. NadiaBase sixty-two characters: digits, small letters and capitals. Seven characters give about three and a half trillion codes. Roughly thirty-two thousand times more than the links we expect.
  29. NadiaThe second diagram shows four ways in four columns. A, a counter written in base sixty-two. Link number one hundred and twenty-five million and one becomes eight, s, u, d, three. Shortest possible, and never a clash.
  30. ImranThat sounds perfect. Short and unique. I'd pick A.
  31. NadiaThen the next code is eight s u d four. And the next, eight s u d five. Anyone can walk through all your receipts.
  32. ImranToken forty-eight on the floor. OK, not A.
  33. NadiaB, a hash of the long URL, cut to seven characters. Same URL, same code. But two different URLs can still clash, so you need a check anyway, and two owners shortening the same URL share one code and mix their stats.
  34. NadiaC, our choice. Seven random characters from a cryptographic random generator, then insert. The unique index rejects the rare clash and you draw again. With a hundred and ten million links stored, a new code clashes about once in thirty-two thousand tries.
  35. ImranAnd D?
  36. NadiaSnowflake-style IDs, from time, machine number and a counter. Great for tens of thousands of writes a second across many servers. Ten to eleven characters, and it leaks the creation time. We write about one per second.
  37. ImranSo random codes are unguessable, and we're done with privacy.
  38. NadiaOne at a time, yes. But guesses add up. An attacker tries a million random seven-character codes. Each one hits a real link with a chance of a hundred and ten million in three and a half trillion. A million guesses find about thirty-one real links.
  39. ImranThirty-one customers' receipts. That's not fine.
  40. NadiaFor campaign offers sent to thousands anyway, it's fine. For receipts, it isn't. With ten characters, the same million guesses find about a ten-thousandth of a link. So seven characters for campaigns, ten for private links, plus rate limits at the edge.
  41. ImranAnd three extra characters in an SMS is a cheap price for privacy.
  42. NadiaExactly. Next deep dive, fast redirects. Cache-aside, with three additions. Warm on create, because a receipt link is usually opened within minutes. Cache the misses for five minutes, so a scanner trying random codes is answered by Redis, not Postgres.
  43. NadiaAnd never cache past expiry. And if Redis is slow or down, treat it as a miss within fifty milliseconds and read Postgres. A unique index lookup takes well under a millisecond inside the database, so Postgres can carry the load for a while.
  44. ImranThen there's the status code. Three oh one or three oh two.
  45. NadiaThree oh one means moved permanently. Browsers remember it, and may go straight to the long URL next time without asking you. Three oh two means found, for now, so the browser asks you again on the next click.
  46. ImranThree oh one sounds faster. Fewer requests for us.
  47. NadiaFaster, and here's what it costs. The third diagram shows a phone, the short link service and the shop site. With three oh one, the second click goes from the phone straight to the shop. We never see it. We can't count it.
  48. ImranAnd with three oh two, every click comes back to us.
  49. NadiaSo counts and takedowns work. Here's the failure story. Marketing sends an offer with the wrong price to two hundred thousand customers.
  50. ImranWith three oh two, we disable or correct the link, delete its cache key, and it's fixed within seconds. Even for people who clicked an hour ago.
  51. NadiaWith three oh one, everyone who already clicked keeps landing on the wrong page. So for this product, three oh two wins. It sends a ninety-second phone cache, so a double tap stays on the phone.
  52. ImranLast deep dive. Counting clicks without slowing redirects.
  53. NadiaA Postgres insert on every click would be two thousand inserts a second at peak, inside the customer's wait. Instead, the API adds a small event to a Redis Stream and doesn't wait for the answer.
  54. NadiaA worker reads up to five hundred at a time, inserts them in one statement, and only then acknowledges them. If it crashes in between, they stay pending. On restart it reads them again, and the insert skips any click ID it already has, so nothing is counted twice.
  55. ImranWhat if the worker is down for a long time?
  56. NadiaThe stream is capped at about two million entries. Roughly five hours of clicks at the average rate, or seventeen minutes at the peak. After that, the oldest unread clicks are lost. The requirements allow it, so it's accepted on purpose, with an alert on the lag.
  57. ImranAnd abuse? Someone could make a fake mobile wallet login page look like our link.
  58. NadiaThat's an open redirect. Because the callers are internal, create only accepts https links to an allow-list of our own domains. And per-IP limits stay generous, because mobile operators put many customers behind one shared IP.
  59. NadiaNow the Build-it, in words. A base sixty-two helper and a random code function that uses the crypto library, never plain Math dot random. Then Prisma models: a link table where the code compares case-sensitively, a click table partitioned by month, and a small daily totals table.
  60. NadiaThen two Redis servers with different jobs. The cache may evict keys. The stream must never evict, and keeps an append-only file on disk. Then create, which retries on a code clash and returns the first link on a repeated external reference.
  61. ImranAnd the redirect itself?
  62. NadiaIt rejects junk paths before any lookup, resolves the code through the cache, adds the click without awaiting it, and answers three oh two. The worker's single SQL statement inserts clicks and counts only the rows really inserted. Last, a nightly purge and a monthly partition drop.
  63. ImranCan I try the base sixty-two part without installing anything?
  64. NadiaYes. The practice section has two small JavaScript exercises that run in your browser. One encodes and decodes base sixty-two. The other works out how long a code must be, so that a million guesses find almost nothing.
  65. ImranMy review questions for anything that hands out codes or links. If someone changes one character of a link, can they see somebody else's data?
  66. NadiaThat's the best question in the whole review. It alone moves the team from counter codes to random ten-character ones.
  67. ImranThen: do we need counts and takedowns, and if so, why would this be a three oh one? And what happens to redirects when Redis is down?
  68. NadiaA question to think about. The click worker inserted five hundred clicks, then crashed before acknowledging them. After restart, are they lost, or counted twice? Take a second.
  69. ImranNeither. It re-reads its pending entries, the insert skips IDs it already has, and only new rows are counted.
  70. NadiaExactly. At-least-once delivery with an idempotent consumer, the same pattern as the outbox.
  71. ImranMy takeaways. One: start with numbers, because they showed tiny writes, heavy reads, and no need to shard. Two: random codes with a retry, and longer ones for private links, because guesses add up. Three: three oh two keeps clicks coming back, so we can count and fix links.
  72. NadiaAnd one addition: record clicks in the background, and write down what an outage may lose. Next time, a case study that's all ours: the sales from every till in every store, reaching head office exactly once.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Capacity plan

Expected peak traffic, a sized design and Find my limit, with headroom and cost per month as criteria.

Open in the System Design Studio
Next stopCase study: syncing POS sales from hundreds of stores
Was this lesson helpful?
Questions and discussion

Loading the discussion…