Design a URL Shortener (TinyURL)
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | Short links for one company's marketing team | A public shortener anyone can use, with click analytics | Global; enterprises bring their own domains; takedowns in minutes |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| New links | ~1M/month | 100M/month | ~1B/month |
| Redirects | ~40/s average, ~2K/s peak | ~3,860/s average, ~12K/s peak, plus viral bursts of 50K/s on one link | ~38,600/s average, ~200K/s peak worldwide |
| Footprint | 1 region, 3 AZs | 1 region + CloudFront edge | 3 regions, active-active, + edge |
| Availability | 99.9% | 99.99% | 99.999% for redirects; takedown within 5 min worldwide |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a URL Shortener?
You Already Know One: a Forwarding Address
When you move house, you can ask the post office to forward your mail. Letters still go to the old address, and the post office sends them on to the new one. A URL shortener is a forwarding address for web pages. It hands out a short, easy address, and anyone who visits it is sent on to the long one:
texthttps://hi.link/aZ9k2Lq │ └──► https://shop.example.com/products/winter-jacket?utm_source=newsletter&utm_medium=email&utm_campaign=winter_sale_2026&utm_content=hero_banner
The part after the slash, aZ9k2Lq, is the short code. The service keeps one record per code: this code forwards to that long URL. When a browser asks for hi.link/aZ9k2Lq, the service answers with a redirect: an HTTP response whose status is in the 3xx range and whose Location header holds the long URL. The browser then goes there on its own.
| Where short links show up | Why a short link helps |
|---|---|
| Printed flyers and QR codes | Fewer characters to type; a QR code with fewer characters has fewer, bigger squares and scans more easily |
| Social posts and text messages | Character limits, and long URLs look like spam |
| Marketing emails | One link per campaign, so we can count clicks per campaign |
What Makes It Interesting
Three things make a system this simple worth a whole loop:
- It is almost all reads. People click links far more often than they create them. We'll use 100 clicks per link created. Almost every component we add exists to make the redirect faster or cheaper.
- The code must be short, never reused, and not guessable. Short means few characters. Never reused means two links can never get the same code, not even years apart. Not guessable means nobody can walk through every link we have by trying codes in order.
- A redirect must feel instant. The user clicked to go somewhere else. Every millisecond we add is pure waiting.
The Question the Whole Loop Answers
How do we hand out short codes that never collide, and answer every click in milliseconds, at any scale?
The answer gets sharper every round:
- Round 1: a counter that can't repeat, turned into a 7-character code, and a cache in front of one table.
- Round 2: the same counter scrambled so codes can't be guessed, an edge cache for viral links, click analytics that never slow a redirect, and abuse control.
- Round 3: codes created in every region without talking to each other, custom domains, takedowns everywhere in minutes, and a bill where the CDN is most of the cost.
Round 1 · Mid-level · "Short Links for Our Marketing Team"
~35 min · SDE II (L5) · 1 region, 3 AZs · ~1M links/month · ~2K redirects/s peak · 99.9%
R1.1 Establish Design Scope
The interviewer says: "Our marketing team wants short links for their campaigns. Design the service." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many new links per month? | About a million. Every email, ad and flyer gets its own links. | About 0.4 new links per second on average. Creating links will never be the hard part. |
| How many clicks per link? What's the read-to-write ratio? | About 100 clicks per link on average. | 100 million redirects a month, about 40 per second. We optimize the read path first. |
| Is traffic steady? | No. A campaign email goes to millions of customers, and most clicks arrive in the first minutes. | Peaks far above the average, often on one link. We plan for about 2,000 redirects per second, most of them on the newest campaign link. |
| How short must the codes be? | As short as is practical. Our printed materials use 7 characters after the domain. | We have a length budget. We'll prove 7 characters is plenty (step 1.1). |
| Can links expire? | Yes, optionally. A campaign ends, and its links should stop working. | Each link may carry an expiry time, and expired links must stop redirecting at that time, not "eventually". |
| Can a link's target be changed after it's created? | No. Once a link is printed, it points where it points. | Records are written once and read many times. That makes caching easy. |
Custom aliases like hi.link/wintersale? | Not yet. | Every code is generated by us, so we control the whole code space for now. |
| Click analytics? | Not yet. | The redirect path only has to redirect. |
Out of scope for this round:
- Custom aliases. They mean codes chosen by users, which can collide with each other and with ours.
- Analytics. Counting clicks sounds free. It isn't, and Round 2 shows why.
- Public sign-up. Only employees create links, so abuse isn't a problem yet.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, some of it comes back.
R1.2 Functional Requirements, Derived Step by Step
We read the request one phrase at a time and turn each phrase into a requirement:
| Phrase from the request | Requirement |
|---|---|
| "Make a long link short" | create(long_url) returns a new short code and short URL |
| "Clicking it goes to the page" | redirect(code) answers with a redirect to the long URL |
| "Campaign links end" | A link may have an expiry time; after it, redirect refuses |
| "Nobody else gets my code" | Two links never share a code, ever |
Not yet: custom aliases, click counts, and sign-up for the public. The interviewer may bring these back.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions and why each matters:
- Redirects must be fast and always up. A broken short link on a printed flyer can't be fixed. If we are down, every campaign is down.
- Codes must never collide. A collision sends one campaign's customers to another campaign's page. That's a silent, embarrassing bug.
- Durability: a link must never be lost. Once a code is printed on 100,000 flyers, its record must outlive any server.
- Codes should be short. Short enough to print and type, which sets a hard limit on how many codes we can have.
R1.4 The API
Create a link (internal; the caller signs in with the company's single sign-on token):
httpPOST /v1/links HTTP/1.1 Host: api.hi.link Authorization: Bearer <company SSO token> Content-Type: application/json { "long_url": "https://shop.example.com/products/winter-jacket?utm_source=newsletter&utm_campaign=winter_sale_2026", "expires_at": "2026-12-31T23:59:59Z" }
httpHTTP/1.1 201 Created Content-Type: application/json Location: https://api.hi.link/v1/links/015FTGg { "code": "015FTGg", "short_url": "https://hi.link/015FTGg", "long_url": "https://shop.example.com/products/winter-jacket?utm_source=newsletter&utm_campaign=winter_sale_2026", "created_at": "2026-09-26T12:00:00Z", "expires_at": "2026-12-31T23:59:59Z" }
expires_at is optional. We reject a long_url that isn't http or https, that is longer than 2,048 characters (a limit we choose), or that points back at hi.link itself, which would create a redirect loop.
Follow a link (public, no sign-in):
httpGET /015FTGg HTTP/1.1 Host: hi.link
httpHTTP/1.1 302 Found Location: https://shop.example.com/products/winter-jacket?utm_source=newsletter&utm_campaign=winter_sale_2026 Cache-Control: private, no-store
Which 3xx status to send, and what Cache-Control to send with it, is a real decision with real consequences. Step 1.4 makes it.
| Status | When |
|---|---|
201 Created | A link was created |
400 Bad Request | The long URL is invalid, too long, or points at us |
302 Found | A redirect (step 1.4 explains why 302) |
404 Not Found | No link has this code |
410 Gone | The link existed and has expired, or an admin disabled it. We know it's gone for good, and 410 says exactly that. 404 would also be acceptable. |
Recap
- Two calls: create a link, follow a link.
- About 1 million links a month and 100 clicks per link; peaks of about 2,000 redirects per second on one campaign link.
- Codes: short (7 characters), never reused, generated by us.
- Links never change target; they may expire, and must stop working on time.
- Redirects are the product. They must be fast and always up.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From a Table Lookup to a Cached Redirect Service
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
One server and one table. The table maps a code to a long URL.
Synthesizing vector architecture diagram...
What's good about it: it works, and it's easy to explain. For a few hundred links, this is the right answer.
What it costs us: everything below. We haven't said how codes are made, one server is a single point of failure, and every click is a database query.
Step 1.1: How Do We Make the Code?
The problem: every new link needs a code of at most 7 characters that no other link has ever had, or ever will. What would you do? Where does the code come from?
Primitive: Distributed Unique ID Generators
How many codes do 7 characters give us? Each character has 62 choices, so:
How long that lasts depends on how fast we create links. The table shows each length at each round's rate:
| Length | Codes | Years at 1M/month (Round 1) | Years at 100M/month (Round 2) | Years at 1B/month (Round 3) |
|---|---|---|---|---|
| 5 | 916,132,832 | 76 | 0.76 (about 9 months) | 0.08 (about a month) |
| 6 | 56,800,235,584 | 4,733 | 47 | 4.7 |
| 7 | 3,521,614,606,208 | 293,468 | 2,935 | 293 |
| 8 | 218,340,105,584,896 | 18,195,009 | 181,950 | 18,195 |
The arithmetic for the bold row: at 1M links a month we make links a year, and years. Six characters would be enough for Round 1. We choose 7 because the company may grow, and because the interviewer told us printed materials already use 7: the table shows 7 still lasts nearly 300 years at a thousand times our traffic.
Encoding one number by hand. Base62 works exactly like writing a number in base 10, just with 62 digits:
textdigit value: 0-9 → '0'-'9' 10-35 → 'a'-'z' 36-61 → 'A'-'Z'
To encode, divide by 62 again and again; each remainder is one digit, read from last to first. Take counter value 1,000,000,000:
| Step | Number | ÷ 62 = quotient | Remainder | Digit |
|---|---|---|---|---|
| 1 | 1,000,000,000 | 16,129,032 | 16 | g |
| 2 | 16,129,032 | 260,145 | 42 | G |
| 3 | 260,145 | 4,195 | 55 | T |
| 4 | 4,195 | 67 | 41 | F |
| 5 | 67 | 1 | 5 | 5 |
| 6 | 1 | 0 | 1 | 1 |
Reading the digits from step 6 back to step 1 gives 15FTGg, six characters. We pad on the left with the zero digit to reach 7: 015FTGg. Check it by multiplying back: .
The same thing as pseudocode:
textALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" encode(n): # requires 0 ≤ n < 62^7 code = "" repeat 7 times: code = ALPHABET[n mod 62] + code n = n div 62 # whole-number division return code # always exactly 7 characters
Running the loop exactly 7 times does the padding for us: once n reaches 0, each remaining step adds a 0.
The counter must stay below , which is about 41.7 bits. : it's more than (2,199,023,255,552) but less than (4,398,046,511,104). A number that doesn't fit needs more characters. A 64-bit ID, such as a Snowflake ID from a company-wide ID service, can be as large as about , and is smaller than that, so such an ID needs 11 Base62 characters. That's why we don't reuse a 64-bit ID generator here: we run our own counter, which starts at 0 and stays far below for centuries.
Step 1.2: Several App Servers Need Counter Values Without Clashing
The problem: we run three app servers, one per AZ, so that losing one doesn't stop us. All three create links. If each keeps its own counter in memory, all three hand out 0, 1, 2, ... and their codes collide. What would you do? How do three servers share one counter?
The counter row, and the lease as a DynamoDB UpdateItem request:
| Attribute | Type | Example | Meaning |
|---|---|---|---|
name (partition key) | String | links | Which counter this is |
next_id | Number | 58000 | The first number not yet handed out |
json{ "TableName": "counters", "Key": { "name": { "S": "links" } }, "UpdateExpression": "ADD next_id :block", "ExpressionAttributeValues": { ":block": { "N": "1000" } }, "ReturnValues": "UPDATED_NEW" }
DynamoDB answers with the new next_id; the server's block is [next_id − 1000, next_id − 1]. No condition is needed: ADD on a number is applied atomically, one request after another.
How often does this happen? At our average of 0.39 links a second, all servers together lease a block about every seconds, roughly every 43 minutes. The counter row is almost idle.
Step 1.3: Redirects Hit the Database Every Time
The problem: a campaign email goes to 5 million customers. In the first minutes, about 2,000 clicks per second arrive, nearly all for one code. Every click is a database read of the same item. What would you do?
How big is the cache? A campaign's clicks mostly happen within weeks of sending. So we plan for the links created in the last 30 days (1 million) plus as many older links still being clicked, about 2 million entries:
600 bytes per record is our estimate: the long URL averages about 500 bytes (marketing URLs carry long tracking parameters), plus the code, times and bookkeeping. With the cache's own per-key overhead, this fits comfortably on the smallest production node type we'd use, a cache.t4g.medium with 3.09 GiB.
At Round 1's scale, be honest in the interview: the table alone could serve our average load, and the cache costs more per month than the table reads it saves (R1.7). We add it for the hot link and for the latency of every click. In Round 2 it becomes essential.
Primitive: Distributed Cache Patterns & Eviction
Step 1.4: 301 or 302?
The problem: the redirect response needs a status code. HTTP offers four that fit: 301, 302, 307 and 308. Which one, and does it matter? What would you do?
Step 1.5: An Expired Link Still Redirects
The problem: a campaign ended at midnight, and its links have expires_at set to midnight. At 9 a.m., a customer clicks one and still lands on the old sale page, with prices that are no longer valid.
What would you do?
textredirect(code): record = cache.get(code) if record is missing: record = table.get(code) # eventually consistent read if record is missing: return 404 cache.put_if_absent(code, record, # SET ... NX EX: never replaces a newer entry lifetime = min(1 day, record.expires_at − now)) if record.status ≠ active: return 410 # disabled by an admin if record.expires_at is set and now ≥ record.expires_at: return 410 return 302 with Location = record.long_url
A refill only writes the cache if the key is empty (SET ... NX), and disabling a link overwrites the cache entry with the disabled record instead of deleting it. That closes a race: a redirect that read the table just before an admin disabled the link can't put the old "active" record back, because the key is no longer empty.
Step 1.6: The Table Needs to Scale
The problem: we started with "one table". Which database, and how should the table be keyed, so that it keeps working as the company grows? What would you do?
The links table
| Attribute | Type | Example | Meaning |
|---|---|---|---|
code (partition key) | String | 015FTGg | The short code |
long_url | String | https://shop.example.com/... | Where the link goes |
owner_id | String | emp_48213 | Who created it (GSI partition key) |
created_at | Number | 1790424000 | Unix time in seconds (GSI sort key) |
expires_at | Number | 1798761599 | Unix seconds; optional |
status | String | active | active or disabled (by an admin) |
purge_at | Number | 1801353599 | The TTL attribute: expires_at + 30 days; cleanup only |
The GSI by_owner has partition key owner_id, sort key created_at, and projects only code and long_url, so it stays small.
Primitive: Database Sharding & Partition Keys
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | One server, one table | Single point of failure; a query per click |
| 1.1 | How to make the code | Counter + Base62, 7 characters (3.52 trillion codes) | A shared counter; sequential, guessable codes |
| 1.2 | Servers share the counter | Leased blocks of 1,000 via one atomic ADD | Gaps after restarts (harmless) |
| 1.3 | Every click hits the table | ElastiCache (Valkey), cache-aside | Stale copies of expired or disabled links |
| 1.4 | 301 or 302 | 302 + Cache-Control: private, no-store | Every click reaches us |
| 1.5 | Expired links still redirect | Check expires_at on every read; TTL for cleanup only | One comparison per read |
| 1.6 | Which store | DynamoDB keyed by code; GSI by owner | Access patterns fixed up front |
Sequential codes, from step 1.1, are the first thing Round 2 must fix.
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Follow a click from the top: the load balancer picks an app server, which asks the cache first (1) and the links table only on a miss (2). The dotted arrows are off the click path: servers lease counter blocks rarely, and only when creating links.
The pieces:
- Application Load Balancer (ALB): terminates HTTPS and spreads requests over the app servers in three AZs. It checks each server's health and stops sending to a failed one.
- App servers: three EC2 instances in an Auto Scaling group, one per AZ, each a
c7g.medium(1 vCPU). The same process serves bothPOST /v1/linksandGET /{code}, and keeps its current counter block in memory. - Cache: ElastiCache for Valkey, one primary and one replica in different AZs, with automatic failover.
- Tables: DynamoDB
linksandcounters, on-demand capacity (pay per request, no capacity to plan).
Trace 1: creating a link.
Synthesizing vector architecture diagram...
Most creates never touch the counter table: the number comes from memory. The only write on the normal path is the new link. Check the encoding: , and 14, 51, 22 are e, P, m. The next link gets 0000ePn, which is exactly the guessability problem Round 2 fixes.
Trace 2: a redirect, cache hit and cache miss.
Synthesizing vector architecture diagram...
A hit costs one sub-millisecond cache read. A miss adds one table read and one cache write, and only the first click on a link in a day pays it.
R1.7 Numbers
Traffic. We use a 30-day month, 2,592,000 seconds.
| Quantity | Math | Value |
|---|---|---|
| New links, average | 1,000,000 ÷ 2,592,000 s | ≈ 0.39/s |
| New links, peak | a campaign tool creating links in bulk (assumption) | ~10/s |
| Redirects per month | 1,000,000 × 100 | 100,000,000 |
| Redirects, average | 100,000,000 ÷ 2,592,000 s | ≈ 38.6/s |
| Redirects, peak | a campaign email's first minutes (assumption, about 50 × average) | ~2,000/s |
Storage.
DynamoDB already stores three copies across three AZs, and its storage price includes them, so we don't multiply by 3.
Cache: about 2 million entries × 600 B ≈ 1.2 GB (step 1.3), on a 3.09 GiB node.
Code space: 3.52 trillion 7-character codes last about 293,000 years at this rate (step 1.1).
App servers. We plan on 2,000 redirects per second per c7g.medium, an assumption we confirm with a load test. Three servers give 6,000/s; after losing an AZ, two give 4,000/s against a 2,000/s peak.
Availability budget. 99.9% of a 30-day month (43,200 minutes) allows 43.2 minutes of downtime.
Latency. A cache hit is one sub-millisecond read inside the region; a miss adds a DynamoDB read, typically a few milliseconds. The P99 target of 50 ms leaves room for the ALB and TLS.
Monthly cost (us-east-1, on-demand, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
3 × c7g.medium | 3 × $0.0363/h × 730 h | ≈ $80 |
| ALB, fixed | $0.0225/h × 730 h | ≈ $16 |
| ALB, capacity units | ~39 new connections/s ÷ 25 per unit ≈ 1.5 units × $0.008/h × 730 h | ≈ $9 |
ElastiCache, 2 × cache.t4g.medium (Valkey) | 2 × $0.052/h × 730 h | ≈ $76 |
| DynamoDB writes | 1M links × 2 writes (table + GSI) = 2M × $0.625 per million | ≈ $1.25 |
| DynamoDB reads | ~5M cache misses × 0.5 read units × $0.125 per million | ≈ $0.30 |
| DynamoDB storage | 7.2 GB after a year × $0.25/GB | ≈ $2 |
| CloudWatch metrics and alarms | ≈ $10 | |
| Total | ≈ $195/month |
An eventually consistent read of an item up to 4 KB costs half a read unit; that's where the 0.5 comes from. Say it plainly: the table costs about $4 a month; the cache costs $76. We keep the cache for the hot campaign link and for latency, not to save money.
R1.8 Trade-Offs
How to make codes
| Scheme | Collisions | Work per create | Guessable? | Notes |
|---|---|---|---|---|
| Counter + Base62 (chosen) | Impossible | Memory, plus one counter lease per 1,000 links | Yes, sequential (Round 2 fixes it) | Needs a shared counter |
| Hash of the URL, truncated to 7 characters | ~20 in year one, growing as | A hash, a read to check, retries | No | Same URL, same code |
| Random code + check | Rare, but must be checked | A read before every write, retries | No | Retries grow as the space fills |
| Pre-generated key pool | Impossible | Take a key from a table of unused keys | No | A second table to fill, and a "reserve, then mark used" step that must not hand the same key to two servers; it's the counter problem again, moved into a table |
Redirect status
| Choice | Pros | Cons |
|---|---|---|
302 + no-store (chosen) | Expiry and disabling work on the next click; every click is visible to us | Every click reaches our servers |
| 301 | Returning visitors skip us: less load, faster | Browsers keep going to the old target after expiry or disabling; we can't count or stop them |
DynamoDB vs PostgreSQL at this size. Either works. PostgreSQL gives SQL, joins for ad-hoc questions and a unique index on code; we'd run RDS or Aurora with a standby in another AZ. DynamoDB gives single-digit-millisecond key lookups, no servers or failovers to manage, and growth to Round 2 without resharding. We choose DynamoDB for the growth path, not because PostgreSQL would fail here.
R1.9 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| The cache primary fails | Cache errors for tens of seconds; latency rises | The app treats a cache error as a miss and reads DynamoDB. ElastiCache promotes the replica. At 2,000 reads/s the table copes: DynamoDB documents that a new on-demand table sustains up to 12,000 reads per second, and at 0.5 read units per read that's well within reach. |
| The counter table is unavailable | Lease errors in logs; lease-error alarm | Servers keep creating links from the blocks they hold. At 0.39 links/s, three blocks of 1,000 last hours. Redirects never touch the counter. |
| An app server dies mid-block | ALB health check fails; the Auto Scaling group replaces it | The unused numbers in its block are never handed out, a gap of at most 1,000. The new server leases a fresh block; nothing repeats. |
| One AZ fails | A third of the servers and possibly the cache primary are gone | Two servers carry 4,000/s against a 2,000/s peak; the cache fails over to the other AZ; DynamoDB is already multi-AZ. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Servers in three AZs behind an ALB; a Multi-AZ cache that falls back to the table; DynamoDB's built-in multi-AZ storage; leased counter blocks so creates survive a counter outage REL 10 · REL 11 |
| Performance Efficiency | A cache on the read path for the hot link; a key-value table matched to a key-lookup access pattern; 302 chosen on purpose PERF 3 |
| Security | HTTPS only; create requires the company's SSO; target URLs validated (scheme, length, no self-loops); app servers' IAM role may only read and write the two tables SEC 3 · SEC 9 |
| Cost Optimization | About $195 a month; on-demand DynamoDB because traffic is spiky and small; we say out loud that the cache isn't there to save money COST 6 · COST 7 |
| Operational Excellence | Light this round: alarms on redirect P99 latency, 5xx rate, cache hit ratio and counter-lease errors OPS 8 |
| Sustainability | Skipped this round. |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks for the read-to-write ratio and the traffic shape, and says the ratio means "optimize the read path".
- Does the code-length math: trillion, and how many years that lasts.
- Rejects hash truncation with the birthday bound, not just "it might collide".
- Makes codes from a counter, and shares it with leased blocks.
- Chooses 302 over 301, and explains the browser cache.
- Checks expiry on read and knows TTL deletion is lazy.
Follow-up questions
-
"Why Base62 and not Base64?" Answer: standard Base64 uses
+and/, which mean something in URLs. But there's a URL-safe Base64 variant (RFC 4648 calls it "base64url") that uses-and_instead, so "Base64 isn't URL-safe" isn't the real reason. The real reasons are practical. Codes are read aloud, typed from paper and selected with a double-click, and in many text fields a double-click stops at-, and an underline hides_. And the gain is small: , only 25% more codes at the same length. Some services do use base64url alphabets (YouTube's video IDs contain-and_), so either is defensible. If people will type codes, consider going further and dropping look-alike characters (0/O,1/l/I), as Base58 does. -
"Two people shorten the same URL. Do they get the same code?" Answer: no. Each create takes a new counter value, so they get two codes. That's what we want here: two campaigns pointing at the same page want separate expiry dates, and in Round 2, separate click counts. If a user wants "give me my existing link for this URL", we can add an index on (owner, hash of the long URL) and look it up first, per owner, never across owners.
-
"How long do 7 characters last?" Answer: codes. At 1 million links a month, about 293,000 years; at 100 million a month, about 2,900 years; at a billion a month, about 293 years. Length is not what forces a change. Round 3 shows that guessability is.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Take the first 7 characters of an MD5 hash" | Truncated hashes collide by the birthday bound: about 20 collisions in year one, and at Round 2's rate about 200,000 in the first year alone (it grows as ). |
| "301 everywhere, it's faster" | Browsers cache 301s by default; expired or disabled links keep working in their caches, and we can't count or stop those clicks. |
| "The database's TTL will expire links" | DynamoDB TTL deletes expired items typically within a few days, and reads return them until then. Expiry must be checked on read. |
| "Use the company's 64-bit Snowflake IDs as codes" | A 64-bit ID needs 11 Base62 characters, not 7. |
Round 2 · Senior · "A Public Shortener With Analytics"
~40 min · Senior SDE (L6) · 1 region, 3 AZs + CloudFront edge · 100M links/month · ~12K redirects/s peak, viral bursts of 50K/s · 99.99% · redirect P99 < 10 ms
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built an internal shortener for the marketing team: about a million links a month, 100 clicks per link, so about 40 redirects a second on average and 2,000 at a campaign's peak, in one region across three AZs at 99.9%. Codes come from a counter written in Base62 with 7 characters, which gives trillion codes, nearly 300,000 years at that rate. The counter must stay below , about 41.7 bits, which is why we don't use 64-bit IDs. App servers lease blocks of 1,000 counter values with one atomic
ADDon a DynamoDB row, so the counter is almost idle and a crash only leaves a gap. Links live in a DynamoDB table keyed by code, with an index by owner. Redirects read ElastiCache first, then the table. We answer 302 withno-store, never 301, so expiry and disabling work on the next click, and we checkexpires_aton every read because DynamoDB's TTL deletes lazily. About $195 a month. Open costs: codes are sequential and guessable, there are no analytics or custom aliases, there's no edge, and nothing stops abuse."
Architecture v1, compact
textbrowsers ──► ALB ──► 3 app servers (one per AZ): create + redirect │ 1. ElastiCache (Valkey): link:{code} → {long_url, expires_at} │ 2. on miss: DynamoDB links (key = code; GSI by owner) │ check expires_at on every read → 302 (no-store) or 410 └── rarely: DynamoDB counters, ADD next_id 1000 → a block of codes
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | How to make the code | Counter + Base62, 7 characters | Sequential, guessable codes |
| 1.2 | Servers share the counter | Leased blocks of 1,000 | Harmless gaps |
| 1.3 | Every click hits the table | ElastiCache, cache-aside | Stale copies |
| 1.4 | 301 or 302 | 302 + no-store | Every click reaches us |
| 1.5 | Expired links still redirect | Check expires_at on read | One comparison |
| 1.6 | Which store | DynamoDB by code, GSI by owner | Fixed access patterns |
Open costs: guessable codes; no analytics; no custom aliases; one region and no edge; no abuse control.
R2.1 The Scope Raise
Interviewer: "Marketing loved it. Now we're turning it into a public product, like Bitly. Anyone on the internet can create links, 100 million a month. Business customers want click counts, top referrers and countries for each link. They also want custom aliases like
hi.link/summit-2026. Last month a celebrity posted one of our links and it got 50,000 clicks a second for twenty minutes. Attackers have started using shorteners to hide phishing pages. We need redirects under 10 ms at P99, and 99.99% availability."
A scope raise is not the end of scoping. Before we fix anything, we ask back, and we say what each answer changes, just as in R1.1.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Who can create links, and how? | Anyone, through a web form, without an account. Businesses use an API key. | Creation is now an attack surface: we need rate limits per API key and per IP, and bot checks on the form (step 2.4). |
| Are some links private? | Yes. Customers link to unlisted documents and expect nobody to find them without the link. | Sequential codes are now a data leak. Codes must not be guessable by counting (step 2.3). |
| How fresh must analytics be, and for how long do we keep them? | Within a couple of minutes. Two years of history. | Real-time counters aren't required. A stream with one-minute windows is enough (step 2.2). |
| How big is a viral spike, and on how many links? | Up to 50,000 clicks a second on one link, a few times a month. | The everyday peak is about 12,000 a second; a viral link adds bursts four times that on one key. Round 1's 6,000-reads-per-second partition ceiling is now a real limit (step 2.1). |
| Custom aliases: who gets them, and can they be reassigned? | Business customers. First come, first served. An alias is never given to someone else, even after it expires. | Alias claims need an atomic "only if nobody has it" write, and expired aliases must never be deleted and reclaimed (step 2.5). |
| Do customers create links in bulk? | Yes, imports of up to a million links at once. | Bursts of writes, much of it for one owner: throttling risk (step 2.5). |
| P99 under 10 ms, measured where? | At our servers, edge or origin, not including the user's own network. | We can meet it from an edge cache or from memory, not from a database read on every click. |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Who creates links | Employees | Anyone; businesses via API keys |
| New links | ~1M/month | 100M/month (≈ 38.6/s average, ≈ 200/s peak) |
| Redirects | ~40/s average, ~2K/s peak | ≈ 3,860/s average, ≈ 12K/s peak, + viral bursts of 50K/s on one link |
| Data | ~7 GB/year | 7.2 TB over 10 years (12 billion links) |
| Footprint | 1 region, 3 AZs | 1 region, 3 AZs, + CloudFront edge |
| Features | Create, redirect, expiry | + custom aliases, click analytics, bulk import, abuse protection |
| Availability | 99.9% (43.2 min/month) | 99.99% (4.3 min/month) |
| Latency | P99 < 50 ms | P99 < 10 ms at our servers |
The "Not yet" list from R1.2 is now mandatory: custom aliases, click counts and public sign-up. The public sign-up changes the design most, because it makes guessable codes and abuse into real problems.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| Sequential codes | Anyone can walk through every link: 0000ePm, 0000ePn, ... including customers' unlisted documents. It also tells competitors exactly how many links we make. |
| No analytics | The obvious fix, a counter updated on every redirect, turns 3,860 reads a second into 3,860 writes a second, and a viral link into 50,000 writes a second on one item. A DynamoDB write unit costs 5 times a read unit ($0.625 vs $0.125 per million), and a small eventually consistent read uses only half a read unit, so each counter write costs about 10 times the table read it counts. |
| Cache entries expire after a day | When a viral link's cache entry expires, thousands of requests miss at once and all go to the table: a stampede. |
| No abuse control | Attackers mint links to phishing pages. Browsers and mail filters then block our whole domain, and every customer's links stop working. |
| Codes only we generate | A customer's alias like summit-2026 can be claimed twice at the same moment, and could equal a code we generate. |
| Direct writes for every create | A million-link import from one customer throttles the table, and in particular the one index partition that holds that customer's links. |
R2.3 New Requirements and API Additions
Create, with an optional alias (API key required for aliases and bulk):
httpPOST /v1/links HTTP/1.1 Host: api.hi.link Authorization: Bearer hk_live_8f2a... Content-Type: application/json { "long_url": "https://events.example.com/summit/2026/register", "alias": "summit-2026", "expires_at": "2026-11-30T00:00:00Z" }
httpHTTP/1.1 201 Created Content-Type: application/json { "code": "summit-2026", "short_url": "https://hi.link/summit-2026", "safety_status": "pending", "created_at": "2026-09-26T12:00:00Z", "expires_at": "2026-11-30T00:00:00Z" }
| Status | When |
|---|---|
201 Created | Created. safety_status starts as pending until the scanner has checked the target (step 2.4). |
202 Accepted | A bulk import was queued; the response carries a job ID to poll |
409 Conflict | The alias is taken (or was ever taken; aliases are never reassigned) |
422 Unprocessable Content | The alias breaks the rules: 4–64 characters from a-z A-Z 0-9 -; it must contain a - or have a length other than 7 or 8 (the lengths we generate, now and after Round 3's move to 8), so an alias can never equal a generated code; not a reserved word such as v1, api, login or robots.txt |
429 Too Many Requests | The API key or IP is over its creation quota, with Retry-After |
Stats for one link (owner's API key only):
httpGET /v1/links/summit-2026/stats?window=7d HTTP/1.1 Host: api.hi.link Authorization: Bearer hk_live_8f2a...
httpHTTP/1.1 200 OK Content-Type: application/json { "code": "summit-2026", "window": "7d", "total_clicks": 14290, "top_referrers": [ { "host": "t.co", "clicks": 6120 }, { "host": "news.ycombinator.com", "clicks": 2210 } ], "top_countries": [ { "country": "US", "clicks": 8010 }, { "country": "DE", "clicks": 1330 } ], "updated_at": "2026-09-26T12:01:00Z" }
updated_at tells the caller how fresh the numbers are: we promise within about two minutes (step 2.2).
API keys and quotas. Every business customer gets API keys. A key is sent as a bearer token; we store only a hash of it. Each key has a creation quota (for example 100 links a minute on the free plan, more on paid plans), and anonymous creation through the web form is limited per IP and protected by a bot challenge.
The analytics data model: raw events, then pre-aggregated windows. Two stores, for two different questions:
| Store | Key | Holds | Answers |
|---|---|---|---|
| Raw click events, S3, Parquet files | s3://hi-clicks/dt=2026-09-26/hour=12/part-0001.parquet | One row per click: code, ts, referrer_host, country, device_class, edge_location, cache_result | Anything, later: "clicks from Germany on mobile last Tuesday", via SQL in Athena, in seconds to minutes |
Aggregated windows, DynamoDB click_stats | partition key code, sort key HOUR#2026-09-26T12 | clicks, a capped map of top referrer hosts, a capped map of countries | The stats endpoint, in one small query |
Parquet is a columnar file format: values of one column are stored together, which compresses well and lets a query read only the columns it needs. Athena runs SQL directly over files in S3 and charges by the data scanned.
R2.4 Design Evolution: Viral Links, Analytics, Enumeration and Abuse
Step 2.1: A Viral Link's Cache Entry Expired, and 50K Requests Hit the Table at Once
The problem: a celebrity posts hi.link/aZ9k2Lq. It gets 50,000 clicks a second. Its cache entry expires after a day, in the middle of the spike. For the next few milliseconds, every request misses the cache, and thousands of reads of one item hit DynamoDB, whose partition caps out at 6,000 eventually consistent reads a second. Reads are throttled, the app servers retry, and redirect latency jumps to hundreds of milliseconds.
What would you do?
Synthesizing vector architecture diagram...
The spike is spread over hundreds of edge caches, each of which asks us about once a day. The origin sees a handful of requests for the viral code, not 50,000 a second.
Primitive: Distributed Cache Patterns & Eviction · Drill: Caching a hot product page
Step 2.2: Counting Clicks Costs More Than Serving Them
The problem: customers want click counts, referrers and countries per link, within a couple of minutes. And since step 2.1, most clicks are answered by CloudFront and never reach our servers. What would you do? Where do clicks get counted, and where are the counts stored?
Why the aggregator writes the Parquet files, not Amazon Data Firehose. Firehose is the usual no-code way to land a stream in S3 as Parquet, and it would work with a Lambda transform to JSON: its Parquet conversion accepts only JSON input, and CloudFront's real-time log records aren't JSON. And Firehose bills a stream record by its size rounded up to the nearest 5 KB. Our records are about 500 bytes, so we'd pay for ten times the data: at 10 billion clicks a month, about 51 TB billed instead of 5 TB. Since the Flink job already reads every record, it writes the files itself. If producers batched events into records near 5 KB, Firehose would be the simpler choice.
Primitive: Message Queues vs Event Streams
Step 2.3: Anyone Can Walk Through Every Link
The problem: a researcher shows that after creating one link, 0000ePm, they can visit 0000ePn, 0000ePo, ... and read other customers' unlisted documents. They also estimate our volume from how fast the codes grow.
What would you do? We want to keep the counter, because it makes collisions impossible.
The permutation, as pseudocode
textDOMAIN = 62^7 # 3,521,614,606,208 codes feistel(x): # a keyed permutation of 0 … 2^42 − 1 L = x div 2^21 ; R = x mod 2^21 # two 21-bit halves for round in 0, 1, 2, 3: f = first 21 bits of HMAC-SHA256(SECRET_KEY, round, R) L, R = R, L XOR f return L × 2^21 + R scramble(counter): # counter < DOMAIN y = feistel(counter) while y ≥ DOMAIN: # cycle walking y = feistel(y) return y # also < DOMAIN, and unique per counter code = encode(scramble(counter)) # encode() from step 1.1
A worked example, with toy numbers you can check by hand. Real halves are 21 bits; here they're 4 bits, so numbers run 0 to 255, and our pretend code space is 0 to 199 (78% of 256, close to the real 80%). Three rounds, with round function and round keys .
Counter value 50 is L = 3, R = 2 (because ):
| Round | f = (5R + k) mod 16 | New L (old R) | New R (old L XOR f) |
|---|---|---|---|
| 1 | (10 + 3) mod 16 = 13 | 2 | 3 XOR 13 = 14 |
| 2 | (70 + 7) mod 16 = 13 | 14 | 2 XOR 13 = 15 |
| 3 | (75 + 11) mod 16 = 6 | 15 | 14 XOR 6 = 8 |
The result is . That's above 199, not a valid code, so we walk: permute 248 (L = 15, R = 8):
| Round | f | New L | New R |
|---|---|---|---|
| 1 | (40 + 3) mod 16 = 11 | 8 | 15 XOR 11 = 4 |
| 2 | (20 + 7) mod 16 = 11 | 4 | 8 XOR 11 = 3 |
| 3 | (15 + 11) mod 16 = 10 | 3 | 4 XOR 10 = 14 |
Result , which fits. So counter 50 becomes 62, and its neighbour 51 becomes something unrelated. Running all 200 inputs through this toy gives 200 different outputs, all below 200: a permutation, as promised. (The toy's round function is far too weak for real use; the real one is a keyed hash.)
Go deeper: NIST standardizes a format-preserving encryption mode, FF1 (SP 800-38G), built from the same Feistel idea over any radix, including base 62. It's the "use a reviewed construction" answer if the interviewer pushes on the strength of a home-made Feistel network. Either way, treat the permutation as protection against enumeration, not as encryption of anything secret.
Step 2.4: Our Domain Got Blocklisted for Phishing
The problem: attackers create thousands of hi.link codes pointing at fake bank login pages and send them by text message. A browser vendor's safe-browsing list and a large mail provider start flagging hi.link itself. Now every customer's links show a red warning page or land in spam.
What would you do?
Primitive: Distributed Rate Limiting · Bot Defense & Registration Abuse · Drill: Rate limiting a partner API · Loop: Design a Distributed Rate Limiter
Step 2.5: Custom Aliases Collide, and Bulk Imports Throttle the Table
The problem: two customers ask for summit-2026 within the same second, and both get 201 Created; one of them now points at the other's page. Separately, a customer imports a million links, and after a few seconds creates start failing with throttling errors, for everyone.
What would you do?
The alias claim:
json{ "TableName": "links", "Item": { "code": { "S": "summit-2026" }, "long_url": { "S": "https://events.example.com/summit/2026/register" }, "owner_id": { "S": "cust_7731" }, "owner_shard": { "S": "cust_7731#9" }, "kind": { "S": "alias" }, "safety_status": { "S": "pending" }, "created_at": { "N": "1790424000" }, "expires_at": { "N": "1795996800" } }, "ConditionExpression": "attribute_not_exists(code)" }
Primitive: Database Sharding & Partition Keys · Drill: Sharding tenant hotspot
Step 2.6: Random Codes That Don't Exist Are Hammering the Table
The problem: a scraper tries random codes, 2,000 a second, looking for links (step 2.3 left random guessing possible). Almost every guess is a code nobody has. Each one misses CloudFront (every code is different), misses the cache (we only cache links that exist), and reads the table. What would you do?
Primitive: Bloom Filters & Counting Filters
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Viral link stampedes the table | CloudFront caching 302s (s-maxage=86400, stale-while-revalidate=3600, browsers no-store); single-flight at the origin | Edge copies delay revocation; the edge hides clicks |
| 2.2 | Counting clicks costs more than serving | CloudFront real-time logs → Kinesis → Flink: 1-minute windows into hour rows; raw Parquet in S3 | 1–2 minute lag; a second pipeline |
| 2.3 | Codes can be walked | Feistel permutation over with cycle walking into | A fixed secret key; random guessing still possible |
| 2.4 | Phishing gets us blocklisted | Async scanning with safety_status; 403 page; invalidation on block; creation rate limits | A short unscanned window; scanner cost |
| 2.5 | Aliases collide; imports throttle | Conditional writes for every create; aliases never reassigned; queued imports; sharded owner index | Async bulk; 16-way index reads |
| 2.6 | Random probes hit the table | Negative cache (5 min), overridden by create; 404 limits; 10 s edge 404 cache | A probed code can briefly 404 at one edge |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Two independent paths. The click path runs top to bottom on the left: edge first, origin only on a miss, and nothing on it waits for analytics or scanning. Clicks are counted from the edge's log on the right, so the redirect never writes anything.
Trace 1: a viral redirect at the edge.
Synthesizing vector architecture diagram...
Trace 2: a create, then the scan.
Synthesizing vector architecture diagram...
Trace 3: a stats read. GET /v1/links/summit-2026/stats?window=7d → the app checks that the API key owns the link → one Query on click_stats with code = summit-2026 and sort key between HOUR#2026-09-19T12 and HOUR#2026-09-26T12 → 168 rows of a few hundred bytes → the app sums clicks and merges the referrer and country maps → 200. The raw lake is never touched.
Cache key patterns (ElastiCache)
| Key | Value | Lifetime | Written by |
|---|---|---|---|
link:{code} | {long_url, expires_at, safety_status} | 1 day, or until expiry if sooner | Redirect on a miss; create |
neg:{code} | 1 | 5 minutes | Redirect on a table miss; deleted by create |
rl:create:{api_key}:{minute} | counter | 2 minutes | Create rate limit |
rl:404:{ip}:{minute} | counter | 2 minutes | 404 rate limit |
Analytics schemas
| Store | Schema |
|---|---|
Kinesis edge-logs record (CloudFront real-time log, fields we select) | timestamp, c-ip, cs-uri-stem, sc-status, cs-referer, cs-user-agent, c-country, x-edge-location, x-edge-result-type |
S3 Parquet clicks (partitioned dt=, hour=) | code STRING, ts TIMESTAMP, referrer_host STRING, country STRING, device_class STRING, edge_location STRING, cache_result STRING |
DynamoDB click_stats | PK code, SK HOUR#yyyy-mm-ddThh; clicks (Number), referrers (Map, top 20 hosts), countries (Map) |
We store the referrer's host and a device class, not full referrer URLs or raw IP addresses: the stats never need them, and not storing them is the easiest privacy decision we'll make.
R2.6 Numbers and Cost
Traffic (30-day month = 2,592,000 s)
| Quantity | Math | Value |
|---|---|---|
| Writes, average | 100,000,000 ÷ 2,592,000 s | ≈ 38.6/s |
| Writes, peak | 5 × average | ≈ 193, call it 200/s |
| Reads, average | 38.6 × 100 | ≈ 3,860/s |
| Reads, peak | 3 × average: 3 × 3,858 = 11,574 | ≈ 12K/s |
| Clicks per day | 3,858 × 86,400 | ≈ 333M |
| Clicks per month | 3,858 × 2,592,000 | ≈ 10.0B |
The 12K/s is the everyday peak. A viral link's 50K/s comes on top of it, for minutes, on one code, and the edge absorbs it.
Counter. At 38.6 creates a second and blocks of 1,000, all servers together lease a block every 26 seconds: about 0.04 writes a second on the counter row.
Storage
DynamoDB keeps three copies across AZs inside that price; we don't bill 21.6 TB.
Cache (hot set). An upper bound: assume at most 20% of a day's clicks are to distinct codes (the rest are repeats of popular links). Then:
ElastiCache recommends keeping about a quarter of each node's memory free for its own work, so we need about GB of node memory: 3 shards of cache.r7g.xlarge (26.32 GiB each, 79 GiB in total), each with one replica in another AZ.
Edge hit ratio and origin load. How often a click finds its link already cached at its edge location depends on how clicks spread over links and locations, which we can't derive; we assume 70% and measure it from the real-time logs (x-edge-result-type).
| Average | Peak | |
|---|---|---|
| At the edge | 3,860/s | ~12K/s (+ 50K/s viral) |
| Reaching the origin (30%) | ≈ 1,160/s | ≈ 3,500/s (+ a handful for the viral code) |
| Reaching DynamoDB (10% of origin, cache misses) | ≈ 116/s | ≈ 350/s |
App servers. We plan on 5,000 redirects a second per c7g.large (an assumption to load-test) and size the fleet for the whole 12K peak, in case the CDN serves nothing (a bad cache policy deploy, say): 2 per AZ, 6 in total. After losing an AZ, 4 × 5,000 = 20,000/s.
Kinesis shards. One real-time log record per click, about 500 bytes. A shard takes 1,000 records a second, so records, not bytes, set the count: 12K/s everyday peak plus a 50K/s viral burst is 62K/s, so 64 shards. We provision shards rather than use on-demand mode, because an on-demand stream only absorbs up to double its previous peak instantly, and a viral burst is four times ours.
Availability. 99.99% of a 30-day month is 4.3 minutes. The edge helps here too: during a short origin outage, CloudFront keeps serving cached links, and for expired entries stale-if-error could extend that if we chose to add it.
Monthly cost (us-east-1; CloudFront at United States prices)
| Item | Math | Monthly |
|---|---|---|
| CloudFront HTTPS requests | 10.0B × $0.0100 per 10,000 | ≈ $10,000 |
| CloudFront data out | 10.0B × ~600 B (headers + Location) = 6 TB × $0.085/GB | ≈ $510 |
| CloudFront invalidations (blocked links) | ~100K paths − 1,000 free × $0.005 | ≈ $495 |
| CloudFront real-time logs | 10.0B lines × $0.01 per million | ≈ $100 |
| Kinesis, 64 shards | 64 × $0.015/h × 730 h | ≈ $700 |
| Kinesis PUT payload units | 10.0B records × $0.014 per million | ≈ $140 |
| Managed Flink, 9 KPUs (assumption, 8 + 1 for orchestration) | 9 × $0.11/h × 730 h | ≈ $720 |
DynamoDB links writes | 100M links × 2 (table + index) × $0.625 per million | ≈ $125 |
| DynamoDB scanner verdicts | 100M safety_status updates × $0.625 per million | ≈ $63 |
DynamoDB links reads | 116/s × 2,592,000 s ≈ 300M × 0.5 unit × $0.125 per million | ≈ $19 |
DynamoDB links storage | 720 GB after year 1 × $0.25 ($1,800 by year 10) | ≈ $180 |
DynamoDB click_stats, provisioned | ~1,200 write units × $0.00065/h × 730 h (assumes clicks bunch ~4 per code per minute) | ≈ $570 |
ElastiCache, 6 × cache.r7g.xlarge (Valkey) | 6 × $0.3496/h × 730 h | ≈ $1,530 |
EC2, 6 × c7g.large | 6 × $0.0725/h × 730 h | ≈ $320 |
| ALBs (two) | fixed + a few capacity units | ≈ $50 |
| S3 lake | ~50 B per click in Parquet (assumption) × 333M/day ≈ 500 GB/month; 6 TB after a year × $0.023 | ≈ $140 |
| Athena | ~10 TB scanned × $5/TB (assumption) | ≈ $50 |
| Scanner (SQS + Lambda), excluding the reputation service's own fees | ≈ $60 | |
| AWS WAF on the API | ACL, rules, ~100M requests × $0.60 per million | ≈ $70 |
| CloudWatch | ≈ $300 | |
| Total | ≈ $16,100/month |
Three things to say about this bill:
- The CDN is about 70% of it, almost all of it the per-request fee. CloudFront bills every viewer request, hit or miss. We're paying for speed near users and for absorbing viral spikes, not to save origin cost: serving the same 10 billion requests from the ALB would cost far less in fees. Say that trade-off out loud.
- The cache costs 80 times the table reads it saves ($1,530 against $19 of reads). It's there for the 10 ms P99 and for hot keys, not for money.
click_statsuses provisioned capacity because its writes are steady; on-demand would be about $0.625 per million writes, roughly 2.8 times the provisioned cost for this flat load.
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Edge caching vs revocation speed | Cache 302s at the edge for a day; invalidate blocked links | A disabled or expired link can redirect from an edge until invalidated or the day ends; invalidations cost money per path |
| Async analytics vs real-time counts | 1-minute windows from the edge log | Counts lag 1–2 minutes; exact real-time counts would need a write per click on a hot key |
| Permutation vs random codes | Counter + Feistel permutation | A secret key we can't rotate within the 7-character space; random codes would avoid the key but need collision checks and retries |
| Lambda vs containers for redirects | Long-running containers on EC2 (or ECS) | Lambda would scale to zero and remove servers, but a standard Lambda execution environment handles one request at a time and opens its own cache connections, cold starts add latency at exactly the moment of a spike, and the per-request price at a steady 1,000+ requests a second costs more than a few instances. SnapStart helps cold starts only for Java 11+, Python 3.12+ and .NET 8+, and can't be combined with provisioned concurrency. |
| DynamoDB vs Aurora | DynamoDB | Aurora PostgreSQL would handle 7.2 TB and this read rate with replicas, and give SQL. But we'd own sharding past one writer, failovers and connection limits. Our access pattern is key lookups plus one index; DynamoDB fits it with no servers. |
| Real-time logs vs origin events | Count from CloudFront real-time logs | We pay per log line and run 64 shards; origin events would be free but miss the 70% of clicks the edge answers |
R2.8 Failure Modes
| Failure | Trigger | What you'd see | How the design responds |
|---|---|---|---|
| Cache stampede | A hot code's cache entry expires or is evicted | A burst of cache misses for one code | Single-flight lets one request per server per code reach the table; CloudFront's stale-while-revalidate means the edge refreshes in the background rather than all at once |
| Hot partition throttling | A bulk import by one customer; a viral code read without the edge | ThrottledRequests on the table or on the owner index | Imports are queued and paced; the owner index is sharded 16 ways; hot reads are absorbed by the edge, the cache and single-flight |
| Connection exhaustion | A burst of new clients opening cache connections (for example, redirect functions scaling out) | Cache CPU climbs; connection errors; latency | We run a fixed fleet with pooled connections. ElastiCache caps each node at 65,000 client connections (not adjustable), and performance suffers long before that; any serverless path needs a connection cap (reserved concurrency) |
| Analytics pipeline lags | Flink falls behind, or a shard is over its limit | Stats updated_at falls behind; Kinesis iterator age grows | Redirects are unaffected: nothing on the click path waits for the pipeline. Kinesis keeps records for 24 hours by default, so Flink catches up without loss if it recovers within that |
| Scanner outage | The reputation service is down or rate-limits us | Scan queue grows; links stay pending | Links fail to "unscanned", never to "safe": pending stays pending. Trusted keys' links redirect; anonymous links keep showing the interstitial. We alarm on the age of the oldest queued message |
| Origin down, edge up | A bad deploy or an AZ-wide problem | 5xx for edge misses | Cached links keep working from the edge. New or uncached links fail until the origin recovers |
R2.9 Production Gotchas
| Gotcha | Symptom | Cause | Fix |
|---|---|---|---|
| Click counters on the read path | Redirect latency rises with traffic; throttling on viral links | A write per click, 50K/s on one item against a 1,000 writes/s partition limit | Count from the edge log in windows; one write per code per minute |
| Sequential codes | Unlisted documents found by strangers; volume leaked | Counter values encoded directly | Keyed permutation (Feistel + cycle walking) before encoding |
| The 301 caching trap | Disabled phishing links keep working for some users; click counts drop | Browsers cache 301s by default, sometimes indefinitely | 302, and private, no-store to browsers |
| Counting at the origin behind a CDN | Stats show a fraction of real clicks | Edge hits never reach the origin | Count from CloudFront real-time logs |
| Firehose with small records | The Firehose bill is 10 times the data volume | Records under 5 KB are billed as 5 KB | Batch records to near 5 KB, or write Parquet from the stream processor |
| Unbounded concurrency starving the cache's connections | Connection errors and latency spikes during bursts | Every new function instance or pod opens its own connections | Pooled clients created once per process; a cap on concurrency; a connection proxy if the cap must be high |
| Reclaimable aliases | An old QR code sends people to a stranger's page | Expired aliases deleted by TTL, then claimed again | Aliases are never deleted or reassigned |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | The edge keeps serving cached links through origin trouble; single-flight and stale-while-revalidate stop stampedes; analytics and scanning are decoupled through a stream and a queue, so neither can slow a redirect; imports paced with backoff and jitter REL 4 · REL 5 · REL 10 |
| Performance Efficiency | 302s cached at CloudFront near users; cache and single-flight at the origin; one write per code per minute instead of one per click PERF 3 · PERF 4 |
| Security | In depth: codes permuted so they can't be walked; the permutation key in Secrets Manager under KMS, fixed for the code space; async phishing scans with fail-closed pending; 403 warning pages; creation limits per key and IP, a bot challenge on the form, and 404 limits against guessing; API keys stored only as hashes; referrer hosts and device classes stored instead of raw IPs SEC 2 · SEC 5 · SEC 8 |
| Cost Optimization | About $16K a month, with the CDN request fee as the main line, stated as a deliberate trade; provisioned capacity for steady stats writes; Parquet from the stream processor instead of Firehose at 5 KB per record COST 5 · COST 7 |
| Operational Excellence | Alarms on redirect P99 (edge and origin), 5xx, edge hit ratio, cache hit ratio, Kinesis iterator age, scan-queue age and table throttles, each with a first action OPS 8 |
| Sustainability | Light this round: the edge answers most clicks close to users, so fewer requests cross the network to our region; raw clicks as compressed Parquet with a 13-month lifecycle rule SUS 4 |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Sees that the everyday peak and the viral spike are different problems, and solves the viral one at the edge instead of with a bigger database.
- Knows the per-partition limits (1,000 writes, 3,000 reads a second) and uses them to explain why a counter per click, a hot item or a single-owner index fails.
- Moves counting off the read path and knows the edge hides clicks from the origin.
- Keeps "unique by construction" and adds unguessability with a permutation, and can explain why a Feistel network plus cycle walking is still one-to-one.
- Treats abuse as a design requirement: async scans, a fail-closed status, creation limits, 403 vs 451.
- Finds the subtle races: two alias claims, and a negative-cache entry that hides a brand-new link.
- Prices the design and can say which line dominates and why it's worth it.
Follow-up questions
-
"Why not let browsers cache the redirect for a minute? It would cut our CloudFront bill." Answer: it would cut requests from returning visitors, but we'd lose control over those browsers: a link blocked for phishing would keep working in every browser that cached it, for a minute or however long it chose to keep it, and we'd never see those clicks. Browser caches can't be invalidated. The edge cache can.
-
"Your permutation key leaks. What now?" Answer: an attacker can compute the code for any counter value, so they can walk every link again. We can't change existing codes; they're printed. We stop issuing 7-character codes under that key and move new links to a new key and a new code length (8 characters, Round 3), so new codes can't collide with old ones. For existing links, we lean on the limits: 404 and request-rate limits per IP, and moving customers' sensitive links to long random tokens.
-
"A customer wants to change where their link points. Round 1 said targets never change." Answer: we can offer it for business customers, but every copy must be updated: the table (a conditional update by the owner), the cache (delete the key), and the edge (an invalidation, or wait out the edge TTL). The cost of edits is exactly the cost of revocations, which is why they come together in Round 3's edge TTL decision.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "Increment the click count on each redirect" | Puts a write on the read path, costs about 10 times the read, and hits the 1,000 writes/s partition limit on a viral link. |
| "The CDN will count clicks for us at the origin" | Edge hits never reach the origin. Count from the edge's logs. |
| "Scramble the counter with a hash" | A hash isn't one-to-one on our range, so collisions come back. A permutation is. |
| "Check if the alias exists, then write it" | Check-then-write is a race. Use a conditional write. |
| "404s are free" | Each one crosses the edge, the origin, the cache and the table. |
Round 3 · Architect · "Global, Custom Domains, Takedowns in Minutes"
~45 min · Principal (L7) · 3 regions active-active + edge · ~1B links/month · ~200K redirects/s peak · 99.999% for redirects · takedown within 5 minutes worldwide
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "We run a public shortener in us-east-1: 100 million links a month, about 3,860 redirects a second on average and 12,000 at peak, plus viral bursts of 50,000 a second on one link, at 99.99%. Codes come from a counter leased in blocks of 1,000, scrambled by a keyed Feistel permutation over 42 bits with cycle walking into , then written as 7 Base62 characters: unique by construction and not walkable. CloudFront caches 302s for a day (
s-maxage=86400, stale-while-revalidate=3600) while browsers getno-store, and the origin uses a cache with single-flight in front of DynamoDB. Clicks are counted from CloudFront real-time logs through Kinesis and Flink into hour rows, with raw Parquet in S3. Links startpendingand are scanned asynchronously; blocked links get a 403 and an invalidation. Every create is a conditional write; aliases are never reassigned; bulk imports are queued. A negative cache stops probes, and a create overrides it. About $16,100 a month, about 70% of it CloudFront. Open costs: one region for writes and for the counter; aliases arbitrated by one table; edge copies that live a day; one domain."
Architecture v2, compact
textbrowsers ──► CloudFront (302s cached 1 day; real-time logs) ──misses──► ALB ──► 6 app servers (us-east-1) │ ElastiCache: link:, neg: │ DynamoDB links (conditional writes), counters API ──► WAF + ALB ──► create: counter block → Feistel → Base62 → PutItem if not exists → SQS scan edge logs ──► Kinesis (64 shards) ──► Flink ──► click_stats hour rows + S3 Parquet ──► Athena
Steps so far
| Step | Problem | Component |
|---|---|---|
| 1.1–1.2 | Unique short codes | Counter + Base62 (7 chars), leased blocks of 1,000 |
| 1.3–1.5 | Fast, correct redirects | Cache-aside; 302 + no-store; expires_at checked on read |
| 1.6 | Store | DynamoDB keyed by code |
| 2.1 | Viral links | CloudFront caching 302s; single-flight |
| 2.2 | Analytics | Edge logs → Kinesis → Flink windows; Parquet lake |
| 2.3 | Enumeration | Feistel permutation + cycle walking |
| 2.4 | Phishing | Async scans, safety_status, creation limits |
| 2.5 | Alias races, bulk | Conditional writes; queued imports; sharded owner index |
| 2.6 | Probes | Negative cache overridden by create |
Open costs: one region; aliases and the counter live in one region's tables; a blocked link can live a day at the edge unless invalidated; only hi.link.
R3.1 The Scope Raise
Interviewer: "We're global now. Users on every continent click and create links, and both must be fast everywhere. About a billion new links a month, and 200,000 redirects a second at peak. Enterprise customers want links on their own domains, like
go.acme.com/summit. When we get a court order or a confirmed phishing report, the link must stop working worldwide within five minutes. Enterprises want unique visitors per link, not just clicks. When a customer leaves, their links and click data must be deleted. And we must survive losing a whole region."
Again, we ask back before we design:
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Which regions? | North America, Europe and Asia: us-east-1, eu-west-1 and ap-northeast-1. | Three regions. All three are on the list where DynamoDB's multi-Region strong consistency is available, according to AWS's "How DynamoDB global tables work" page (we verify the exact combination at build time), which matters for aliases (step 3.2). |
| Must creation work in a region when the others are unreachable? | Yes, for generated codes. Aliases may be slower, but must never be given to two customers. | No cross-region call to make a generated code (step 3.1). Aliases need one authority (step 3.2). |
| "Down in five minutes": measured from when, to what? | From the moment we accept the takedown, until no edge location and no region redirects that link. For court orders we must be able to show it. | A worst-case bound we can prove, plus verification and an audit record (step 3.3). |
| How many customer domains, and how fast must onboarding be? | About 2,000 in the first year. Self-service, working within an hour. | Automated domain and certificate setup, no tickets (step 3.4). |
| Must unique-visitor counts be exact? For which links? | Within a couple of percent is fine. Enterprise customers' links only. | An approximate, mergeable sketch per link (step 3.5). |
| What exactly must be deleted when a customer leaves, and how fast? | Their links, their domains, and all click data about their links, within 30 days. | Storage laid out by customer, so deletion is a few bulk operations, not a search (step 3.6). |
| When a region fails, may a just-created link be briefly unavailable? | A few seconds of new links, briefly, yes. A duplicated code, never. | Asynchronous replication for link records is acceptable; code uniqueness must not depend on it. |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Footprint | 1 region + edge | 3 regions active-active + edge |
| New links | 100M/month (≈ 200/s peak) | ~1B/month (≈ 386/s average, ≈ 2K/s peak) |
| Redirects | ≈ 12K/s peak + viral 50K/s | ~200K/s peak worldwide + viral 50K/s |
| Data | 7.2 TB over 10 years | ~7.2 TB per replica per year; tens of TB; ~3.3B click events per day |
| Domains | hi.link | + ~2,000 customer domains with HTTPS |
| Revocation | Invalidate on block; otherwise up to a day | Takedown within 5 minutes worldwide, provable |
| Analytics | Clicks, referrers, countries | + unique visitors (approximate) for enterprise links |
| Data lifecycle | Retention rules | + deletion of a customer's data on exit |
| Availability | 99.99% | 99.999% for redirects (about 26 s/month) |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| All writes in one region | Creates from Tokyo cross the Pacific twice, and if us-east-1 is down, nobody can create anything. |
| One counter row | If each region leases from the same row, creation in every region depends on one region. If the row sits in a replicated table, two regions can both "win" the same block (next line). |
| Alias claims as conditional writes | With DynamoDB global tables in their default mode, a condition is checked only against the local region's copy, and conflicting writes are settled by "last writer wins". Tokyo and Paris can both accept summit-2026; one customer silently loses it later. |
| Edge copies for a day | A court-ordered takedown can keep redirecting from some edge location for up to a day unless every invalidation lands. |
| One distribution, one domain | A CloudFront distribution holds one certificate and, by default, up to 100 extra domain names. It can't serve 2,000 customer domains with their own certificates. |
| Clicks, referrers and countries only | Unique visitors can't be added up: "unique per day" summed over 7 days counts a returning visitor 7 times. Exact sets per link are big and slow to merge. |
| Storage laid out by time only | Deleting one customer's clicks means rewriting every Parquet file that contains any of them. |
R3.3 New Requirements and API Additions
Custom domain onboarding
httpPOST /v1/domains HTTP/1.1 Host: api.hi.link Authorization: Bearer hk_live_ent_... Content-Type: application/json { "domain": "go.acme.com" }
httpHTTP/1.1 202 Accepted Content-Type: application/json { "domain": "go.acme.com", "status": "pending_dns", "dns_instructions": { "type": "CNAME", "name": "go.acme.com", "value": "d1a2b3c4example.cloudfront.net" }, "certificate": "pending_validation" }
GET /v1/domains/go.acme.com reports pending_dns → pending_validation → active. Links on a custom domain are created with "domain": "go.acme.com" in POST /v1/links, and every link is now identified by domain plus code: go.acme.com/summit and hi.link/summit are different links.
Takedown
httpPOST /v1/links/hi.link/aZ9k2Lq:takedown HTTP/1.1 Host: api.hi.link Authorization: Bearer <trust-and-safety staff token> Content-Type: application/json { "reason": "legal_order", "reference": "case-2026-0917-114" }
httpHTTP/1.1 202 Accepted Content-Type: application/json { "takedown_id": "td_01J8Z6K3", "status": "propagating", "accepted_at": "2026-09-26T12:00:00Z", "deadline": "2026-09-26T12:05:00Z" }
GET /v1/takedowns/td_01J8Z6K3 shows each region's cache update, the edge invalidation, and the result of verification probes, ending in verified. reason is legal_order (the link then answers 451) or phishing (it answers 403).
Stats with unique visitors (enterprise links): the Round 2 response gains "unique_visitors_approx": 11804 and "unique_visitors_error": "±1.6% (95%)".
Customer data deletion
httpDELETE /v1/tenants/ten_acme HTTP/1.1 Host: api.hi.link Authorization: Bearer <account owner token, confirmed>
202 Accepted with a job ID; the job's status lists each store and when it was cleared (step 3.6).
R3.4 Design Evolution: Global Reads, Global Writes and Fast Takedowns
Step 3.1: Creates Happen in Every Region, and Codes Must Still Never Collide
The problem: users in Tokyo, Paris and Virginia create links in their nearest region. Each region must be able to create links even if the other two are unreachable. No two links, anywhere, may ever share a code. What would you do?
The far-region miss. Because the code comes from a counter slice, any region can tell which region made a code, without asking anyone:
texthome_region(code): # generated codes only n = decode_base62(code) counter = unscramble(n) # the Feistel rounds run backwards; cycle-walk backwards until < 62^7 slice = counter div 440,201,825,776 return REGION_OF_SLICE[slice] # e.g. slice 2 → ap-northeast-1 redirect on a local miss (cache and local replica both empty): if code is alias-shaped (contains "-", or length is not 7 or 8): claim = strongly consistent GetItem(code) from alias_claims # MRSC, step 3.2 if claim is missing: return 404 record = GetItem(code) from the claim's home region's replica return 302 if found, else 404 home = home_region(code) if home == this region: return 404 if counter > published_high_water_mark[home] + 1,000,000: return 404 # never issued yet record = GetItem(code) from the home region's replica # one cross-region read return 302 if found, else 404
Each region writes its counter's high-water mark (the end of its last leased block) into a small item in the global table once a minute; only that region ever writes it, so last-writer-wins can't lose anything. A published mark lags the real counter: at us-east-1's average of about 154 creates a second (40% of 386), a block of 1,000 lasts about 6.5 seconds, so a minute-old mark can be about 9 blocks behind, and about 46 behind at a 5× peak. Comparing against the bare mark would 404 exactly the new links the fallback exists for, so we compare against the mark plus a margin of 1,000,000, which covers over 20 minutes even at us-east-1's peak (about 770 creates a second). A random probe still almost always decodes to a counter far above every mark plus margin, because each slice has 440 billion values and a region issues about 5 billion a year. Alias-shaped codes never go through this path: they're looked up in the strongly consistent alias_claims table (step 3.2), which records each alias's home region. So probes still stop at the local region, and only genuinely new links pay the cross-region read.
Primitive: Distributed Unique ID Generators · Cloud Disaster Recovery & Multi-Region Active-Active · Loop: Design a Distributed Unique ID Generator
Step 3.2: Two Regions Accepted the Same Custom Alias
The problem: a customer in Tokyo and a customer in Paris both claim hi.link/summit-2026 within the same second. Each region's conditional write (attribute_not_exists) succeeds against its own replica, both customers get 201, and a second later replication overwrites one of the two records.
What would you do?
The same last-writer-wins rule applies to every later update of a link row. If a trust-and-safety update in Paris and a target edit in Virginia hit the same item in the same second, one whole item version wins, and the other change is lost. So we keep mutable facts out of the shared row: link records are written once (plus the owner's rare edits, which go to the link's home region from step 3.1), and takedowns are separate items in a separate takedowns global table, keyed by domain/code. A takedown item is only ever inserted, never updated, so there's nothing for last-writer-wins to lose.
Drill: Multi-region replication consistency
Step 3.3: A Takedown Must Be Global in 5 Minutes, but the Edge Caches for a Day
The problem: a court orders hi.link/aZ9k2Lq removed. It's cached in the regional caches of three regions and at edge locations all over the world, with up to a day left on some copies. We have five minutes, and we must be able to show we met them.
What would you do?
How each response is cached at the edge
| Link state | Status | Edge caching |
|---|---|---|
| Active | 302 | s-maxage=60, stale-while-revalidate=30 |
| Blocked for phishing | 403 warning page | s-maxage=60: CloudFront caches a 403 only when the origin sends max-age or s-maxage, so we send it, and the edge absorbs clicks on a viral phishing link |
| Taken down by legal order | 451 | CloudFront's documentation doesn't list 451 among the codes it caches, so every such request reaches our origin, which answers from the regional cache. Court-ordered links are rare; if one is viral, the origin fleet absorbs it |
| Unknown | 404 | CloudFront's error-caching minimum, 10 s |
Drill: CDN edge image delivery · Primitive: Change Data Capture & Outbox
Step 3.4: Thousands of Customer Domains Need HTTPS
The problem: 2,000 enterprise customers want go.acme.com/..., link.globex.io/... and so on. Each needs a valid HTTPS certificate on our edge, set up in under an hour without a support ticket, and renewed forever.
What would you do?
Step 3.5: Unique Visitors per Link, Over Billions of Clicks
The problem: an enterprise customer asks how many different people clicked their link this week. We see about 3.3 billion clicks a day across all links. What would you do?
Primitive: Bloom Filters & Counting Filters (the same family of probabilistic sketches)
Step 3.6: A Customer Leaves, and Their Data Must Go
The problem: Acme leaves. Their 40 million links, their domain, and every click on those links, across three regions' tables and caches, the edge, and 13 months of Parquet files, must be deleted within 30 days. What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Creates in every region | 8 counter slices of 440.2B; region-local counter rows; links as a global table; home-region fallback on a far miss | A cross-region read for brand-new links |
| 3.2 | Alias claimed twice | MRSC alias_claims table in exactly our 3 regions; takedowns as insert-only items | A cross-region round trip per claim; 3 regions fixed |
| 3.3 | Takedown in 5 minutes | s-maxage=60, stale-while-revalidate=30 (180 s worst case); stream fan-out overwriting regional caches; targeted invalidation; probes | Edge hit ratio ~70% → ~50%; more origin |
| 3.4 | 2,000 customer domains | CloudFront multi-tenant distribution; tenant per domain; managed certificates | Onboarding automation; customers' DNS affects renewal |
| 3.5 | Unique visitors | HLL per link per day (12 KB max, 0.81% standard error), merged across days | Approximate; a stated visitor definition |
| 3.6 | Customer deletion | Tenant key on links; lake partitioned by tenant; tenant-level edge invalidation | Layout driven by deletion; a lookup per click |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region is complete for redirects and generated creates: its own app servers, cache, counter slice and table replicas. Only three things cross regions: table replication (asynchronous), alias claims (synchronous, rare), and the edge log, which CloudFront delivers to one analytics stream.
The analytics pipeline is central, not per region. Clicks are recorded by CloudFront, which is global, and a real-time log configuration names the stream it delivers to. So the edge log arrives in one place, us-east-1, where Flink, click_stats and the lake live. Stats are allowed to be minutes stale and are not part of the 99.999% promise. If us-east-1 is down, stats stop updating; as a backfill source we also enable CloudFront standard access logs to an S3 bucket in eu-west-1.
Trace 1: a link created in Tokyo, clicked in Paris a moment later.
Synthesizing vector architecture diagram...
The code Qx81bTe is illustrative; what matters is that any region can run the permutation backwards and find the slice, and so the home region, without asking anyone.
Trace 2: a takedown. 12:00:00 the takedown API in eu-west-1 inserts takedowns: hi.link/aZ9k2Lq → 12:00:00.8 the item is in us-east-1 and ap-northeast-1 → each region's stream Lambda overwrites link:aZ9k2Lq with "taken down (451)" by 12:00:03 → the court order triggers create-invalidation for /aZ9k2Lq → probes in three regions get 451 through CloudFront → verified at, say, 12:01:10. Had the invalidation failed, the last edge copy would still have expired by 12:03:03 (180 s after the regional overwrites at 12:00:03).
Trace 3: us-east-1 fails. Route 53 health checks on origin.hi.link fail for us-east-1 within a minute or two, and CloudFront's origin requests go to eu-west-1 and ap-northeast-1. Edge copies keep serving meanwhile. Redirects for any link replicated before the failure keep working. Links created in us-east-1 in its last second or so before failing may not have replicated; they 404 elsewhere until the region returns (the RPO the interviewer accepted). API creates are routed to the surviving regions, which use their own counter slices, so nothing collides. Alias claims keep working: MRSC needs two of its three regions. When us-east-1 returns, its replica catches up, and its counter resumes from its own durable row, never behind any block it leased.
R3.6 Numbers and Cost
Traffic
| Quantity | Math | Value |
|---|---|---|
| New links, average | 1,000,000,000 ÷ 2,592,000 s | ≈ 386/s |
| New links, peak | 5 × average | ≈ 1,930/s, call it 2K/s |
| Redirects per month | 1B × 100 | 100B |
| Redirects, average | 100B ÷ 2,592,000 s | ≈ 38,600/s |
| Redirects, peak | given; about 5.2 × average | 200K/s |
| Clicks per day | 38,580 × 86,400 | ≈ 3.33B |
Per region (a planning split of the peak: 40% Americas, 35% Europe, 25% Asia)
| Region | Edge peak | Origin peak at a 50% edge hit ratio |
|---|---|---|
| us-east-1 | 80K/s | 40K/s |
| eu-west-1 | 70K/s | 35K/s |
| ap-northeast-1 | 50K/s | 25K/s |
| Total | 200K/s | 100K/s |
Edge hit ratio against TTL (assumed, to be measured by replaying real-time logs against each TTL before we switch):
| Edge TTL | Worst-case staleness without invalidation | Edge hit ratio (assumed) | Origin peak |
|---|---|---|---|
| 1 day (Round 2) | ~1 day | 70% | 60K/s |
| 5 minutes | ~11 min (300 + 30 + 300 + 30 = 660 s) | 60% | 80K/s |
| 60 s (chosen) | ~3 min (180 s) | 50% | 100K/s |
| none | 0 | 0% | 200K/s |
App servers per region. Still planning 5,000 requests a second per c7g.large. We run 15 per region, 5 per AZ (75K/s capacity):
- AZ loss in the busiest region: (80% busy).
- Region loss: if us-east-1 fails and Route 53 sends about 25K/s of its origin traffic to eu-west-1 and 15K/s to ap-northeast-1, eu-west-1 carries K/s on 75K (80% busy), ap-northeast-1 carries K/s. Auto Scaling adds instances within minutes if it lasts.
Caches. In Round 2 the cache held the whole hot set. At this scale, that would be about GB per region. But a cache miss here costs two eventually consistent item reads (the link and its takedown item), $0.125 per million misses: the cache is worth it for latency and hot keys, not for everything. We run a smaller cache as a shield: 2 shards of cache.r7g.2xlarge plus replicas per region (about 80 GB usable), and assume an 80% hit ratio at the origin.
DynamoDB reads: origin average /s; 20% miss the cache ≈ 3,860/s; each miss reads the link and its takedown item (2 items × 0.5 unit):
Replication. Each link is written in its home region and replicated to two others: TB a month of cross-region transfer.
The code space with region slices. Each slice holds 440.2 billion counter values:
| Region | Share of creates | Links per year | Years until its slice is full |
|---|---|---|---|
| us-east-1 | 40% | 4.8B | 440.2 ÷ 4.8 ≈ 92 |
| eu-west-1 | 35% | 4.2B | ≈ 105 |
| ap-northeast-1 | 25% | 3.0B | ≈ 147 |
Length doesn't force 8 characters for a century. Guessability does, much sooner. After years at 12 billion links a year, a random 7-character guess hits a real link with probability : 0.34% after one year, 1% after about three. We decide in advance: when the issued share passes 1%, new links get 8-character codes (, where even 120 billion links are 0.055% of the space), under a new permutation key over 48 bits (, so about 1.3 passes of cycle walking on average), with the same 8-slice split. Old 7-character codes stay valid forever; the length tells the two apart, so the change needs no flag day.
Analytics. 3.33B clicks a day; at ~50 bytes per click in Parquet, about 167 GB a day, 5 TB a month, about 65 TB with 13 months kept. Kinesis needs one shard per 1,000 records a second: 200K/s peak plus a 50K/s viral burst, so 256 shards. HLL: we assume about 10% of the ~667M codes clicked each day belong to enterprise customers, ~67M daily sketches, each written once, over the hour after the day closes, into the on-demand visitor_sketches table; most are sparse and under 1 KB.
Monthly cost (CloudFront at each geography's own rates; everything else at us-east-1 rates, and the other regions cost somewhat more)
| Item | Math | Monthly |
|---|---|---|
| CloudFront HTTPS requests | 40B × $1.00/M (US) + 35B × $1.20/M (Europe) + 25B × $1.20/M (Japan) | ≈ $112,000 |
| CloudFront data out | 100B × ~600 B = 60 TB, at each geography's tiered rate | ≈ $5,300 |
| CloudFront invalidations | ~300K paths a month (court orders + recently clicked phishing links) × $0.005 | ≈ $1,500 |
| CloudFront real-time logs | 100B lines × $0.01/M | ≈ $1,000 |
| Distribution tenants | ~2,000 tenants, tiered per-tenant fee (about $0.10 each at volume; check current pricing) | ≈ $200 |
| Kinesis, 256 shards + PUT units | 256 × $0.015 × 730 + 100B × $0.014/M | ≈ $4,200 |
| Managed Flink, ~61 KPUs (assumption) | 61 × $0.11 × 730 | ≈ $4,900 |
EC2, 45 × c7g.large | 45 × $0.0725 × 730 | ≈ $2,400 |
| ALBs, 3 regions | ≈ $200 | |
ElastiCache, 3 × 4 × cache.r7g.2xlarge | 12 × $0.6984 × 730 | ≈ $6,100 |
| DynamoDB writes, all replicas | 1B links × 9 (table + 2 indexes, by owner and by tenant, in 3 regions) × $0.625/M | ≈ $5,625 |
| DynamoDB scanner verdicts, all replicas | 1B safety_status updates × 3 regions × $0.625/M | ≈ $1,875 |
| DynamoDB reads | above | ≈ $1,250 |
| DynamoDB storage | 7.2 TB per replica after a year × 3 × $0.25/GB (growing ~$450 a month, every month) | ≈ $5,400 |
| Cross-region replication transfer | 1.2 TB × $0.02/GB | ≈ $25 |
click_stats, provisioned (~12K write units) | 12,000 × $0.00065 × 730 | ≈ $5,700 |
visitor_sketches, on-demand (~2B writes a month) | 67M × 30 ≈ 2.0B × $0.625/M | ≈ $1,250 |
| S3 lake + Athena | 65 TB × $0.023 + scans | ≈ $1,750 |
| Scanner, WAF, alias table, CloudWatch | ≈ $2,800 | |
| Total | ≈ $163,000/month |
The CDN is nearly three quarters of the bill (≈ $120,000 of ≈ $163,000), and nearly all of that is the request fee, which the edge TTL doesn't change. The architect's lever is not the TTL; it's the price per request (committed-use pricing, which AWS negotiates at this volume) and the question of whether every redirect needs a CDN at all (R3.7).
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Edge TTL vs takedown speed vs cost | 60 s + stale-while-revalidate=30; invalidation only where it matters | Origin peak 60K → 100K/s (assumed hit ratios), more app servers and cache traffic. The CDN bill barely moves, since every request is billed |
| Alias authority: MRSC vs home region vs multi-writer | MRSC alias_claims in exactly our 3 regions | A cross-region round trip per claim and a fixed set of 3 regions. A home region would allow any number of regions but stop alias claims while it's down; multi-writer with last-writer-wins would silently give one alias to two customers |
| HLL vs exact unique counts | HLL, 12 KB max per link per day, ±1.6% | Exactness; audits go to the raw lake. Exact sets would be hundreds of MB for a big link and slow to merge across days |
| 7 vs 8 characters | 7 now; 8 for new links once 1% of the 7-character space is issued | Two code lengths forever, and a second permutation key; staying at 7 would let random guessing find a link in 1 of every 100 tries, then worse |
| CloudFront vs regional origins behind Global Accelerator | CloudFront | AWS Global Accelerator routes users to the nearest healthy region over AWS's network, and charges an hourly fee plus a per-GB premium rather than a per-request fee. With 100B small requests a month, the fees would be far smaller (a rough estimate, before origin costs, is in the tens of thousands of dollars rather than $120K). But we'd give up edge caching (every click hits a region, 200K/s plus viral bursts), edge absorption of attacks, and the multi-tenant certificate handling of step 3.4. It's the biggest cost lever we have; we'd test it for hi.link traffic only |
| Build vs a managed shortener | Build | Buying from a shortener vendor is the right call for a company whose product isn't links. For us, the redirect path, the code space and abuse handling are the product |
The opening question was: how do we hand out short codes that never collide, and answer every click in milliseconds, at any scale? The answer is now: codes are a counter, never a guess (sliced by region, scrambled by a permutation, so collisions are impossible by construction and codes can't be walked), and clicks are answered from the closest copy (edge, then regional cache, then local replica), with every copy given a lifetime short enough that revocation is a proof, not a hope.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A region outage | Route 53 health checks fail for one region's origin | CloudFront's origin requests go to the other two; edge copies keep serving; creates use the survivors' counter slices; alias claims continue on MRSC's two remaining regions. Links created in the failed region's last second may be unavailable until it returns |
| Replication lag on new links | ReplicationLatency rises; far-region misses for new codes | The home-region fallback (step 3.1) serves them with a cross-region read; the high-water mark keeps random probes local |
| A takedown misses a cache | Verification probes from one region still get a 302 | The 60 s edge TTL bounds it at 180 s regardless; the takedown workflow retries the regional overwrite and re-sends the invalidation; the probe keeps the takedown open until it sees 451 or 403 |
| A customer's certificate can't renew | The certificate's days-to-expiry alarm; the tenant's validation status fails | Usually the customer changed their DNS. We notify them weeks before expiry. Only that tenant's domain is affected; hi.link and other tenants are untouched |
| The analytics region backs up or fails | Kinesis iterator age grows; stats updated_at stale | Redirects are unaffected. Kinesis keeps records 24 hours for catch-up; for a longer outage, stats are backfilled from standard access logs in eu-west-1 |
| A phishing wave | Scan-queue age and blocked-link rate spike | See the playbook in R3.9: tighten creation limits first, then scanning capacity, and invalidate only links being clicked |
R3.9 Runbook and Incident Response
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Redirect P99 per region (origin) and at the edge | > 10 ms for 5 min | P2 | Check the region's cache hit ratio and app CPU; is one code missing the cache? |
| Redirect success rate (synthetic probes from each region, through CloudFront) | < 99.99% over 5 min | P1 | Which regions and domains? Edge, origin or DNS? |
| Edge hit ratio (real-time logs) | drop > 10 points in 15 min | P3 | A cache-policy or header change? Compare with the last deploy |
| 5xx rate, edge and origin | > 0.1% for 5 min | P1 | Fail the region out of origin.hi.link if it's regional |
| Takedown latency (accepted → verified) | any > 4 min | P1 | Check replication, the stream Lambdas and the invalidation status |
| Scan queue: age of oldest message | > 5 min | P2 | Scanner errors or third-party limits? Scale workers; tighten anonymous creation |
ReplicationLatency (DynamoDB) | > 5 s for 5 min | P2 | Check the receiving region's health; expect far-region fallbacks |
| Kinesis iterator age | > 5 min | P3 | Flink health and KPUs |
| Certificates expiring for tenants | < 21 days | P3 | Notify the customer; check their CNAME |
| CloudFront requests per second vs the distribution quota | > 70% | P3 | Request a quota increase ahead of the next peak |
Takedown procedure (legal order) SEC 10
- Record the order in the takedown API with its reference; the API writes the insert-only takedown item.
- Invalidate the path on the right distribution or distribution tenant.
- Watch the takedown status: three regional overwrites, the invalidation, three probes.
- Verify the audit record shows
verifiedwithin 5 minutes, and attach it to the case. - If a probe still sees a redirect after 4 minutes, page the on-call and re-send steps 2 and 3; the 180 s edge bound means a stale edge copy is not the cause, so look at the regional caches.
Phishing-wave playbook OPS 10
- Contain creation: lower anonymous per-IP limits, require the bot challenge on every form submission, and pause the API keys that created the flagged links.
- Hold new links: switch anonymous links from "interstitial while pending" to "blocked while pending" until the queue drains.
- Scale scanning: raise scanner concurrency within the reputation service's quota.
- Invalidate only what's being clicked: takedowns of links with clicks in the current hour; the 60 s TTL handles the rest.
- Tell the blocklists: report our own takedowns to the safe-browsing providers that flagged us, so
hi.linkitself isn't listed. - Learn: a Correction of Error (COE) with the creation path the attacker used and the limit that should have caught it.
Go deeper: CLI lines
Plain commands an on-call engineer runs, one at a time. Replace the IDs with real ones.
text# 1. Insert a takedown record (insert-only; fails if one already exists) aws dynamodb put-item --region eu-west-1 --table-name takedowns --item '{"link_key": {"S": "hi.link/aZ9k2Lq"}, "reason": {"S": "legal_order"}, "reference": {"S": "case-2026-0917-114"}}' --condition-expression "attribute_not_exists(link_key)" # 2. Invalidate one path on the hi.link distribution aws cloudfront create-invalidation --distribution-id E1A2B3C4D5E6F7 --paths "/aZ9k2Lq" # 3. Invalidate one path on a customer's distribution tenant aws cloudfront create-invalidation-for-distribution-tenant --id dt_2wjDZi3hD1ivOXf6rpZJO1AB --invalidation-batch '{"Paths": {"Quantity": 1, "Items": ["/summit"]}, "CallerReference": "takedown-td_01J8Z6K3"}' # 4. Check that an invalidation has completed aws cloudfront get-invalidation --distribution-id E1A2B3C4D5E6F7 --id I2J0I21PCUYOIK # 5. Which regions hold replicas of the links table aws dynamodb describe-table --region us-east-1 --table-name links --query "Table.Replicas" # 6. Replication latency from us-east-1 to ap-northeast-1 over the last hour aws cloudwatch get-metric-statistics --region us-east-1 --namespace AWS/DynamoDB --metric-name ReplicationLatency --dimensions Name=TableName,Value=links Name=ReceivingRegion,Value=ap-northeast-1 --start-time 2026-09-26T11:00:00Z --end-time 2026-09-26T12:00:00Z --period 60 --statistics Maximum # 7. The edge-log stream's shard count and status aws kinesis describe-stream-summary --region us-east-1 --stream-name edge-logs # 8. How many links are waiting to be scanned aws sqs get-queue-attributes --region us-east-1 --queue-url https://sqs.us-east-1.amazonaws.com/111122223333/scan-queue --attribute-names ApproximateNumberOfMessages # 9. Alarms currently firing aws cloudwatch describe-alarms --region us-east-1 --state-value ALARM --alarm-name-prefix shortener
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Three active-active regions for redirects and generated creates, with no cross-region call on either path; counter slices make regions independent by construction; region-local counters, because a replicated counter would double-issue; MRSC keeps alias claims correct through a region loss; each region survives an AZ loss and can absorb another region's origin traffic REL 10 · REL 11 · REL 13 |
| Performance Efficiency | Redirects from the nearest edge, then the nearest region; brand-new links served by a single cross-region read; a cache sized as a shield, with the replica serving the tail PERF 3 · PERF 4 |
| Security | Takedowns provable within 5 minutes, with an audit record; phishing 403 and legal 451 kept distinct; per-tenant managed certificates; visitors counted by a monthly-rotated keyed hash, never raw IPs; a permutation key per code length SEC 9 · SEC 10 |
| Cost Optimization | ≈ $163K a month, derived, with the CDN request fee as nearly three quarters of it; the edge TTL shown not to move that fee; Global Accelerator identified as the biggest lever; caches shrunk to shields because a cache miss costs $0.125 per million in table reads; invalidation only where clicks exist COST 5 · COST 7 · COST 8 |
| Operational Excellence | Per-region golden signals with first actions; a takedown procedure with verification; a phishing-wave playbook; automated domain onboarding with certificate-expiry alarms; COEs OPS 8 · OPS 10 · OPS 11 |
| Sustainability | Three regions chosen for where users are, so most clicks stay on one continent; raw clicks as compressed Parquet deleted after 13 months; sketches instead of visitor sets; storage laid out so deletion is a prefix, not a rewrite of every file SUS 1 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Makes regions independent by construction (counter slices), and knows exactly which data may be eventually consistent (link records) and which may not (the counter, alias claims).
- Knows what DynamoDB global tables actually guarantee: last writer wins per item, conditions checked locally in the default mode, and MRSC's three-region, no-TTL, no-transaction limits, and designs around them (region-local counters, insert-only takedowns).
- Turns "within 5 minutes" into a proof with a budget, instead of a hope that invalidations land.
- Uses the platform's multi-tenant features for customer domains, and knows the quotas that come with them.
- Chooses approximate answers deliberately (HLL) and states the error and the definition of a visitor.
- Designs storage for deletion from the start.
- Finds that the CDN request fee dominates, notices the TTL doesn't change it, and names the real lever.
Follow-up questions
-
"Why not make the whole
linkstable MRSC and skip the home-region fallback?" Answer: every write, including a billion generated creates a month, would wait for a cross-region acknowledgment, and MRSC doesn't support TTL, which we still use to clean up expired generated links. Generated codes don't need strong consistency, because the counter slices already make them unique. Only aliases do, and they're a small table. -
"A court asks us to prove a link was down everywhere by 12:05." Answer: the takedown's audit record: accepted at 12:00:00, each region's cache overwrite with its time, the invalidation ID and completion time, and the three probe results through CloudFront with their times. Plus the design argument: the edge TTL guarantees that no edge copy could have been older than 180 seconds, even without the invalidation.
-
"We want to add a fourth region, in São Paulo." Answer: redirects and generated creates are easy: give it counter slice 3, a
linksreplica and its own app servers. Aliases are the problem: our MRSC table can't take a fourth replica, and São Paulo isn't on MRSC's region list at the time of writing. São Paulo's alias claims would go to one of the three MRSC regions, paying the round trip, which is acceptable for a rare operation. We'd also check whether the region is worth it from the edge-hit data: many South American clicks may already be answered at the edge.
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: links per month, read ratio, traffic shape, code length, expiry | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API (302, 404/410) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.6: counter + Base62 math → leased blocks → cache → 302 → expiry on read → DynamoDB | Steps 2.1–2.6: edge caching, edge-log analytics, Feistel permutation, async scanning, conditional writes, negative cache | Steps 3.1–3.6: counter slices, MRSC aliases, the 5-minute proof, multi-tenant domains, HLL, deletion by layout |
| 40–50 min | Numbers (code-space table, cache size) + trade-offs | Numbers, cost (the CDN line), trade-offs | Numbers, cost (the CDN request fee), trade-offs |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design, let me ask about the read-to-write ratio and the shape of the traffic, because this system is almost all reads, and I want to know how spiky those reads are."
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in, starting with anything that could give two links the same code or keep a bad link alive."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the cache dies?" (REL 11) | Reads fall back to the table, which handles the peak, while the replica is promoted. | 1 | R1.9 |
| "What happens when a link goes viral?" (REL 5) | The edge answers it, stale-while-revalidate refreshes in the background, and single-flight lets one request per server reach the table. | 2 | Step 2.1 | |
| "What if analytics falls over?" (REL 4) | Nothing on the click path waits for it; the stream keeps 24 hours to catch up. | 2 | R2.8 | |
| "What if a whole region goes down?" (REL 13) | The other regions serve every replicated link and create from their own counter slices; alias claims continue on MRSC's two remaining regions. | 3 | R3.5 | |
| Performance | "How is a redirect fast?" (PERF 3) | Nearest edge, then an in-memory cache, then a single key lookup; nothing is written on the click path. | 1–3 | Steps 1.3, 2.1 |
| "How do users far away get fast redirects?" (PERF 4) | CloudFront edges near them, and origins in three regions behind latency routing. | 2–3 | R3.5 | |
| Security | "Can someone list all your links?" (SEC 5) | Not by counting: codes are a keyed permutation of a counter. Random guessing is limited by 404 limits and, later, by 8-character codes. | 2–3 | Steps 2.3, R3.6 |
| "How do you stop phishing?" (SEC 5) | Async scans with a fail-closed status, creation limits, 403 pages, and a takedown path provable in 5 minutes. | 2–3 | Steps 2.4, 3.3 | |
| "A court order arrives. What happens?" (SEC 10) | An insert-only takedown record, fan-out to every region, an invalidation, probes, and an audit record; the edge TTL bounds the worst case at 180 s. | 3 | Step 3.3, R3.9 | |
| Cost | "What does it cost?" (COST 5) | About $195, $16K and $163K a month; from Round 2 on, the CDN request fee is most of it. | 1–3 | R1.7, R2.6, R3.6 |
| "How would you cut the bill?" (COST 7) | Not with the edge TTL, which doesn't change the per-request fee: negotiate the CDN price, or test regional origins behind Global Accelerator for plain hi.link traffic. | 3 | R3.7 | |
| Operations | "How do you know it's healthy?" (OPS 8) | Probes through the edge from every region, P99 and 5xx per region, edge and cache hit ratios, takedown latency and scan-queue age, each with a first action. | 2–3 | R2.10, R3.9 |
| "What's your plan for an abuse wave?" (OPS 10) | Contain creation first, hold new anonymous links, scale scanning, invalidate only what's clicked, then a COE. | 3 | R3.9 | |
| Sustainability | "Where is the waste?" (SUS 4) | In data kept too long and too wide: we keep compressed Parquet for 13 months, hosts instead of full URLs, and sketches instead of visitor sets. | 2–3 | R2.5, Step 3.5 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Code generation | Counter + Base62; proves trillion and how long it lasts; leased blocks | Keyed Feistel permutation with cycle walking; knows it stops walking, not guessing | Region counter slices; region-local counters; 8 characters when guessability, not length, demands it |
| Read path | Cache in front of a key-value table; 302, not 301 | Edge caching of redirects with browsers kept out; single-flight; negative cache overridden by create | Edge TTL chosen from a takedown budget; caches as shields; home-region fallback for new links |
| Correctness under change | Expiry checked on read, not by TTL deletion | Conditional writes for every create; aliases never reassigned | Knows last-writer-wins and local condition checks in global tables; MRSC for aliases; insert-only takedowns |
| Analytics | Not yet, and says why it isn't free | Counts from the edge log, off the read path, in windows | HLL with a stated error and visitor definition; storage laid out for deletion |
| Abuse and safety | Validates target URLs | Async scans, fail-closed status, creation limits, 403 vs 451 | Takedown in 5 minutes with a proof; phishing-wave playbook |
| Well-Architected trade-offs | Honest that the cache isn't there to save money at this size | Prices the design; names the CDN as the main cost and why it's worth it | Finds that the TTL doesn't move the CDN fee; names the real levers; build vs buy |
| Evolving under new scope | Builds from one table, one problem at a time | Opens with "what breaks"; fixes enumeration and stampedes first | Evolves regions, domains and data lifecycle without breaking a single printed link |