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.
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?
40 GB fits comfortably on one Postgres server, and writes are about one per second, so sharding adds work for no benefit. Keeping links only in Redis makes a cache the source of truth: an eviction or a restart could lose links that must keep working for years.
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.
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 source of truth. click holds one row per redirect, is append-only and is partitioned 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.
Deep dive 1: Generating the short code
This is the classic interview question, and the answer depends on the numbers. A code made of base62 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 collision, 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 Snowflake ID 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 never clashes and gives the shortest codes, so the first and third options describe its strengths. Its weakness is that it is predictable: 8sud3 is followed by 8sud4, so one receipt link leads to all the others.
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 negative caching. 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. 301 means "moved permanently". Browsers remember it and may go straight to the long URL next time without asking you. 302 means "found, for now", so the browser asks you again on the next click. The diagram shows what this means for the product.
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?
With nothing to count and nothing to take down, letting the browser remember the redirect saves every repeat request. Mangrove chose 302 only because it needs counts and the power to fix a wrong link. An extra page adds a step for every reader.
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 Redis Stream 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 consumer group. 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 idempotent 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 open redirect: 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 CGNAT, 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.
# 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 fails | What users see | What the design does |
|---|---|---|
| Cache Redis down | Nothing; redirects a few ms slower | 50 ms command timeout, no offline queue; every lookup reads Postgres by unique index; alert |
| Stream Redis down | Nothing | XADD fails silently; a "clicks dropped" metric counts the loss; alert |
| Click worker crashes | Nothing; stats late | Pending entries re-read on restart; inserts dedupe on the stream id; lag alert |
| Postgres primary down | Hot links still work; new and cold links fail | Cache serves most clicks; failover to a replica (Replication); creates return 503 and callers retry |
| Create times out, caller retries | One link, not two | Unique (owner_id, external_ref) returns the first link |
| Random code already taken | Nothing | Unique index rejects it (P2002); draw a new code, up to 5 tries |
| Wrong URL sent to 200,000 people | Fixed within seconds | 302 means clicks come back: disable or correct the link and delete its cache key |
| Campaign blast, 10x traffic | Slightly slower at worst | Warm cache on create; stateless API scales out; per-IP limits generous for CGNAT |
| Someone scans codes | 429 for the scanner | Random 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.
-
Write the base62 helpers
toBase62is here to show option A, andrandomCodeis option C, the one we use. Usecrypto.randomInt, neverMath.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 charsOutput8sud3 8sud4 125000001n Qu9WIao 7GOfbrNhBr 11Your random codes will differ on every run, which is the point.
-
Define the Prisma models
longUrlis optional because cleanup clears it while the row stays. The composite unique onownerIdandexternalRefis what makes retried creates safe. By default Postgres treats NULLs as distinct in a unique index, so links without anexternalRefnever 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") } -
Edit the migration for what Prisma cannot express
Run
npx prisma migrate dev --create-only --name linksand open the generated file. Change the end of itsCREATE 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; -
Validate input and connect to two Redis servers
The DTO rejects anything that is not an https URL, which blocks
javascript:anddata: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 }) }, ]; -
Create links with a retry on clashes
Prisma raises
P2002when a unique constraint fails. Two different constraints can cause it here: the random code was taken, or the sameexternalRefwas 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 } } } -
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.disabledeletes 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); } } -
Write the redirect endpoint
The shape check runs before any lookup, so junk paths such as
/wp-login.phpcost 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 } } -
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(); -
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; -
Try it
With the API on port 3000, create a link and follow it. The output below is from a test run.
Terminalcurl -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 createOutput{"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) 86752On the stream Redis,
XINFO GROUPS clicksshows alagfield: 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.
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;
}
test('zero is written as "0"', () => expect(toBase62(0)).toBe('0'));
test('61 is the last symbol, 62 needs two characters', () => {
expect(toBase62(61)).toBe('Z');
expect(toBase62(62)).toBe('10');
});
test('the counter example 125000001 becomes 8sud3', () => expect(toBase62(125000001)).toBe('8sud3'));
test('fromBase62 turns codes back into numbers', () => {
expect(fromBase62('8sud3')).toBe(125000001);
expect(fromBase62(toBase62(916132831))).toBe(916132831);
});
test('fromBase62 throws on a character outside the alphabet', () => expect(() => fromBase62('ab-c')).toThrow());
Show a solution
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;
}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.
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
}
test('7 characters, 110 million links, 1 million guesses: about 31 hits', () =>
expect(expectedHits(110e6, 7, 1e6)).toBeCloseTo(31.24, 1));
test('10 characters make the same attack find almost nothing', () =>
expect(expectedHits(110e6, 10, 1e6)).toBeLessThan(0.001));
test('at most 1 expected hit needs 8 characters', () =>
expect(shortestSafeLength(110e6, 1e6, 1)).toBe(8));
test('at most 0.001 expected hits needs 10 characters, as for receipts', () =>
expect(shortestSafeLength(110e6, 1e6, 0.001)).toBe(10));
test('more links need longer codes: 1 billion links needs 11', () =>
expect(shortestSafeLength(1e9, 1e6, 0.001)).toBe(11));
Show a solution
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;
}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?
With a 302, phones ask the shortener again on every click (after the 90-second cache), so a change takes effect at once. A 301 is remembered by the browser, so people who already clicked keep going to the wrong page. A long Redis TTL is fine only because the fix also deletes the key.
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?
Each guess hits with a chance of 110 million out of 3.5 trillion, about 1 in 32,000. A million guesses find about 31. Random codes are unguessable one at a time, but many guesses add up. Ten characters and rate limits push the expected number close to zero.
The click worker inserted a batch of 500 clicks, then crashed before XACK. What happens after it restarts?
Unacknowledged entries stay pending for that consumer. Reading from id 0 returns them again. The primary key is the stream id, so ON CONFLICT DO NOTHING skips them, and only rows returned by the insert are added to the daily counts. This is at-least-once delivery with an idempotent consumer.
Flashcards
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
- RFC 9110: HTTP Semantics (301, 302 and caching rules), IETF, 2022
- Redis Streams, Redis documentation
- ngx_http_limit_req_module, NGINX documentation
- Table Partitioning, PostgreSQL documentation