Design complete systems / CHAPTER 48
Design a URL shortener (TinyURL)
Build unique short links, open them quickly and keep expiry, disabling and private access working as visits grow.
Builds on Functional and non-functional requirements, Back-of-the-envelope estimation, A practical system design template, Caching fundamentals, Data partitioning.
- Prevent two links from acquiring the same code, while serving far more redirects than creations.
- Choose how to generate codes and handle duplicate attempts.
- Use a cache without breaking the stated expiry and access limits.
01Functional requirements
A shortener turns a long destination such as https://garden.example/events/autumn?ref=poster into https://go.example/garden24. Here, garden24 is the short code: the part after the service's domain. The database saves which destination belongs to that code.
FR1: Create unique short links. Let an owner submit a destination and receive a generated code, or choose an available name such as
garden24. If the creation response is lost, retrying the same request must return the same link.FR2: Open active links. When a visitor opens the short URL, find its saved destination and tell the browser to visit it. The browser, rather than the shortening service, loads the destination website.
FR3: Manage links. Let owners set an expiry date, list their links and disable a link. Expired or disabled links must stop working even before their stored data is physically deleted.
FR4: Restrict private links. Let an owner name the accounts allowed to open a private link. Knowing its URL is not sufficient permission.
FR5: Show usage reports. Count recorded visits by day and broad region. Tell owners how current the report is and whether overload caused missed records. This reporting is separate from opening the destination.
Out of scope
- Changing a link's destination after creation, custom domains and page previews.
- Giving a previously used code to another destination.
02Nonfunctional requirements
Use these proposed interview assumptions: ten million new links and one billion visits per day, with peak traffic five times the average. Confirm them before designing capacity.
NFR1: Fast redirects. At peak in the primary region, at least 95% of requests to open public links finish within 100 ms. Loading the destination website is measured separately.
NFR2: Available public links. Meet 99.95% public-redirect availability per calendar month. Creating links and generating reports can fail independently of opening existing links.
NFR3: Reliable creation. Return success only after saving the unique code, destination and original request result. Two owners cannot claim one code. Reject a changed request submitted under an already used request key.
NFR4: Disable within 60 seconds. After the main database saves a disable operation, no cached public copy may serve the link beyond 60 seconds. Copying an old entry must not restart that deadline.
NFR5: Enforce expiry and private permission. Check expiry on each visit. Check the current allowed-account list for private links; deny access if it cannot be checked. Browser and proxy caches must not bypass these checks.
NFR6: Explain report limits. Aim to count accepted visit records within one minute. Count a repeated record once. Counts may include bots and repeated visits by one person, and may miss visits when the reporting queue is full.
NFR7: Never reuse a code. After deleting the destination, keep a small used-code record. An old printed URL must never lead to a new owner's website.
03Estimate only what changes the design
Calculate request rates. A day has 86,400 seconds. Ten million creations ÷ 86,400 ≈ 116 creations/second; one billion visits ÷ 86,400 ≈ 11,600 redirects/second. Multiply by our assumed peak factor of five: 580 creations and 58,000 redirects/second. Opening existing links creates most of the traffic.
Calculate mapping storage. Assume 500 bytes for each code and its link details. One year adds 3.65 billion records: 1.83 TB before overhead. Three copies need about 5.5 TB before indexes, backups and used-code records. At 300 bytes per redirect response, average response traffic is about 3.5 MB/second before protocol overhead. The service sends a destination address, not the destination website's files.
Estimate a useful cache. A cache is an in-memory copy used to avoid repeated database reads. Suppose visitors open 20 million different codes in a day and we keep the most popular 10%. Two million records × 500 bytes = 1 GB before overhead and copies. Ten visits to
garden24still need one entry. Measure how many visits those selected entries actually serve; storing 10% of codes does not automatically avoid 90% of reads.Check the code space. Seven characters chosen from uppercase letters, lowercase letters and digits give 62 choices at each position: 62 to the seventh power, about 3.52 trillion possible codes. One year's 3.65 billion used codes occupy about 0.10%. Six characters give 56.8 billion possibilities and about 6.4% occupancy. Random duplicate choices become more likely as the space fills; the database still checks every new code. A larger space does not replace private-access checks.
Decisions from these estimates
- Test caching popular destinations because redirects dominate traffic.
- Start code creation with random generation and a database uniqueness check. Measure it before adding another allocation service.
- Test an empty or failed cache: up to 58,000 lookups/second could reach the database. Limit that traffic to its tested capacity.
04API Design: Requests, Responses and Retries
Create a link
- The owner signs in and submits the destination. The application uses the signed-in account, rather than trusting an owner name in the submitted JSON.
- The client labels this creation request
create-17. This is an idempotency key: reuse it when retrying this same operation after a timeout. - The service saves the request's result. A retry retrieves that result instead of creating another link.
POST /links
Idempotency-Key: create-17
Content-Type: application/json
{"destination":"https://garden.example/events/autumn?ref=poster","customAlias":"garden24","expiresAt":"2026-11-01T00:00:00Z","visibility":"public"}
{"code":"garden24","shortUrl":"https://go.example/garden24","state":"active","version":1}
| Result | Meaning |
|---|---|
201 Created |
The link and original request result have been saved |
| Same key and same content | Return the original result |
409 Conflict |
The alias is taken, or this request key was already used with different content |
400 Bad Request |
The destination scheme or alias format is invalid |
Manage and open links
GET /links/garden24lets the owner inspect the link;GET /links?cursor=...lists the next page of owned links.DELETE /links/garden24disables it and returns204.- For a link created with
visibility:private, the owner usesPUT /links/garden24/permissionsto replace its allowed-account list. The request suppliesaccountIdsandexpectedVersion; for example,benidentifies Ben’s signed-in account. The permission handler verifies the owner, compares the saved link version, and saves the list plus incremented version in one transaction. Return200with the new version, or409if another edit already changed it. A visitor still checks this current list on every private open. GET /links/garden24/stats?day=2026-10-24returns the owner's report. For example,{"acceptedClicks":23,"through":"2026-10-24T12:00:00Z"}means 23 recorded visits, with processing complete through noon. Later records may still be waiting.- A visitor opens
https://go.example/garden24. The service returns302and aLocationheader containing the saved destination.Cache-Control: no-storetells browsers and proxies not to save that redirect and skip future checks. - Opening returns
404for an unknown code,410for an expired or disabled link,401when sign-in is required and403when the account is not allowed.
05Data Model and Storage Access Patterns
A mapping is the saved connection between a short code and a destination. Keep usage counts separately so reports do not repeatedly change the row used by redirects.
| Stored record and lookup key | What it contains and why |
|---|---|
Link, unique code |
Destination, owner, expiry, visibility, version and disabled time; find the destination for garden24 |
Owner index, (ownerId,createdAt,code) |
Find one owner's links in pages |
Creation result, (ownerId,requestKey) |
Original destination, alias, expiry, visibility and result; compare the creation fields on retry and reject changed requests |
Permission, (code,accountId) |
Decide whether this account may open a private link |
Daily usage, (code,day,region) |
Number of recorded visits for each day and broad region |
Processed visit, unique eventId |
Remember which reporting records have already increased the count |
Why begin with a relational database?
- A unique constraint prevents two saved links from owning
garden24. - A transaction saves the link and creation result together: either both commit or neither does.
- A reporting transaction can likewise save a processed visit ID and increase its daily count together.
- A distributed key-value database is a later option for growth. It still needs a design that preserves these related updates; moving records to separate machines does not supply that guarantee automatically.
06Make one reliable path work
Start with one application, a main database and a backup database copy. The main database accepts changes. The backup can replace it after a failure, using the safeguards described later.
Create the short link
- Mira enters the garden-event destination and asks for
garden24, producinghttps://go.example/garden24. - The application asks the database to insert that code only if it is unused. Two simultaneous requests cannot both pass the database's unique constraint.
- In the same transaction, save the destination, owner and result for creation request
create-17. Return success only after the transaction commits. A retry ofcreate-17returns this saved result. - If Mira did not choose a name, generate a random seven-character code and try the same insert. If it is taken, generate another. Use a cryptographically secure random source, which is designed to make future choices hard to predict.
The example alphabet has 62 characters. Uppercase and lowercase remain distinct in storage and lookup. A URL-safe base-64 alphabet is another choice; ordinary base-64 includes characters needing special URL handling.
Open the short link
- Ben clicks
https://go.example/garden24. The application readsgarden24from the URL path. - The application finds that code's saved row in the main database. It rejects an expired or disabled link and checks Ben's account if the link is private.
- For a permitted link, it sends the destination in a redirect response. Ben's browser then opens the destination website.
Record optional usage statistics
- To provide Mira's usage report, the application creates a small visit record containing the code, time, broad region and a unique record ID. In this example, that ID is
click-81. It identifies this recorded visit, not Ben's account. - Save the record in a queue—a waiting list of work for a background reporting program. The program counts visits after the redirect, so Ben does not wait for a report query.
- Limit the queue and the time spent submitting to it. If it cannot accept a record, still redirect Ben and record that reporting lost an event. Disclose possible undercounting.
- Initially, queue records and daily counts can use the same database. Move reporting to separate resources if measurements show that it slows lookups.
BUILD THE DESIGN, ONE CHANGE AT A TIME
Watch the architecture evolve.
Advance through the stages in this same diagram. Existing components keep their places; additions fill the available space, then add rows. Highlights show what changed from the previous stage, and numbered arrows match the connection key below.
Stage 1 of 3
Start here. First make creation, retries and permission checks correct with one application and database.
Stage 1: look up each link in the database
Follow the numbered arrows
- Browser / creator → Link applicationCreate or open a short URL
- Link application → Main link databaseSave unique code or find destination
- Link application → Browser / creatorReturn saved result or permitted destination
Trace the diagram in words
- Mira saves garden24 and its destination in the main link database.
- Ben opens https://go.example/garden24; the application finds the destination and checks access.
- Commit the mapping and request outcome together.
- Read state and expiry before returning Location.
The cost: Each redirect reads the database. This is simple to follow, but repeated visits to popular links repeat the same read.
Why change it? If repeated visits approach 58,000 redirects per second, the database may spend much of its time looking up the same codes.
Stage 2: reuse temporary destination copies
Follow the numbered arrows
- Browser / creator → Link applicationCreate or open a short URL
- Link application → Main link databaseReserve code or read current link
- Link application → Browser / creatorReturn saved result or permitted destination
- Link application → Mapping cacheRead cache; fill with a fixed deadline
- Mapping cache → Link applicationReturn cached public destination
Trace the diagram in words
- Mira saves garden24 and its destination in the main link database.
- Ben opens https://go.example/garden24; the application finds the destination and checks access.
- A usable cache copy avoids repeating the destination lookup.
- Check an in-memory cache before reading the database.
- Read current database state on a miss and keep the original expiry deadline across cache copies.
Inspect the connection changes
- Now: Link application → Main link database: Reserve code or read current link
- Now: Link application → Mapping cache: Read cache; fill with a fixed deadline
- Now: Mapping cache → Link application: Return cached public destination
- Previously: Link application → Main link database: Save unique code or find destination
All other connections are retained. Response paths are implicit unless drawn.
The cost: Caching reduces repeated reads, but stale entries must expire on time and an empty cache can overload the database.
Why change it? Move reporting onto separate resources when processing clicks and reading reports starts slowing link lookups.
Stage 3: count visits on separate resources
Follow the numbered arrows
- Browser / creator → Link applicationCreate or open a short URL
- Link application → Main link databaseReserve code or read current link
- Link application → Browser / creatorReturn saved result or permitted destination
- Link application → Mapping cacheRead cache; fill with a fixed deadline
- Mapping cache → Link applicationReturn cached public destination
- Link application → Visit-reporting programQueue a visit record for later counting
- Visit-reporting program → Daily visit countsCheck visit ID; add once to daily count
- Link application → Daily visit countsRead the owner’s daily counts
Trace the diagram in words
- Mira saves garden24 and its destination in the main link database.
- Ben opens https://go.example/garden24; the application finds the destination and checks access.
- A usable cache copy avoids repeating the destination lookup.
- A queued visit record is counted once by its saved unique ID.
- Give the click queue and reporting workers separate capacity.
- Read reports from their own store; keep link state in the mapping database.
Inspect the connection changes
- Now: Link application → Visit-reporting program: Queue a visit record for later counting
- Now: Visit-reporting program → Daily visit counts: Check visit ID; add once to daily count
- Now: Link application → Daily visit counts: Read the owner’s daily counts
All other connections are retained. Response paths are implicit unless drawn.
The cost: Reports can lag, so track processing progress, count retries only once and disclose dropped events.
07Compare key allocation choices
The baseline creates a random code when needed. Compare other choices only after explaining what they change:
| Choice | How it works | Cost to consider |
|---|---|---|
| Hash the destination | Calculate a hash and encode part as the code | The same URL gives the same candidate; include owner/request information for independent links. Shortened hashes can still collide. |
| Random code | Choose a candidate and insert only if unused | A duplicate choice needs another attempt; little extra machinery |
| Prepared batches | A separate program reserves unused codes and assigns each server a different batch | Servers do less allocation work per request, but batch assignments must survive crashes |
| Sequence number | Issue a unique integer and encode it with letters and digits | One issuer is simple; multiple issuers must coordinate, and codes are predictable |
How prepared batches recover after a crash
- The allocator—the program assigning batches—saves that batch 91 belongs to server A before replying to A. The batch contains 128 codes.
- A uses seven codes, then crashes. Do not give its batch to B: A may have handed out more codes immediately before crashing than we can now confirm.
- The simplest safe recovery abandons the uncertain remainder and gives B a different batch. At most 121 codes in the uncertain remainder are abandoned, rather than risking a duplicate link.
- If A retries its allocation request, return the saved batch 91 assignment. Do not assign it again as a new operation.
- If the allocator is replaced, prevent the old allocator from assigning batches. The next section on failures explains how storage rejects an old writer.
Generated codes and custom aliases share one set of names, so both must pass the same uniqueness check. At our assumed 580 creations/second, test the simple approach before taking on these batch-recovery rules.
Reserve a code batch before a server uses it
Follow the numbered arrows
- Unused code batches → Saved batch assignmentsSave one exclusive batch assignment
- Saved batch assignments → Server AReply after saving the assignment
- Server A → A crashesKeep crashed server’s batch retired
- Saved batch assignments → Server BAssign a different batch, 92
Trace the diagram in words
- Reserve the block before any server can use it.
- Recover allocations by request identity; never infer non-use from a crashed process.
- Give a replacement server a different batch that does not overlap any earlier allocation.
08Scale the demonstrated bottleneck
1. Add capacity to the measured limit
- More database memory or a faster machine may be enough initially.
- Add application copies behind a load balancer when CPU or open connections become the limit.
- If one database is too large, group codes into logical partitions and assign groups to machines. Move selected groups when adding machines. Directly changing
hash(code) % machineCountcan move many records at once.
2. Cache popular destinations
- Ben opens
https://go.example/garden24. The application looks forgarden24in the in-memory cache. - If an unexpired entry exists, reuse its saved destination after the required access checks.
- If the entry is missing, read
garden24from the main database and save a temporary cache copy. A missing entry is a cache miss; this lookup-then-load pattern is called cache-aside. - For thousands of simultaneous requests for
garden24, let one request perform the database read while the others wait for its result. Limit total waiting and database reads. More database partitions alone cannot help one code that still lives in one partition. - Cache “not found” only briefly, so a new link does not stay hidden behind an old negative answer.
3. Enforce the 60-second disabling rule
- Save the time the current database state was checked, plus link expiry and version, in the cache entry.
- Set its deadline to the earlier of link expiry or 60 seconds after that check. For a check at 11:59:50, the deadline is no later than 12:00:50.
- Copying the entry at 12:00:40 preserves 12:00:50. It does not buy another minute.
- When Mira disables the link, save the change first, then request removal of cache copies. This removal is called invalidation; fixed deadlines still protect the promise if removal is delayed.
- A backup database that has not received the disable change cannot justify a new deadline from its old row. Private links also require a current allowed-account check, even if their destination is cached.
09Trace a write and a read
Follow one visit from URL to report
- Save the link. Mira creates
https://go.example/garden24. The database savesgarden24, its garden-event destination and the result for creation requestcreate-17together. If Mira loses the response, retrying that request returns the same link. - Find the destination. Ben clicks the short URL. The application extracts
garden24from the path. If the cache has no usable entry, it reads the main database and saves a temporary copy with a fixed expiry time. - Open the website. The application checks link expiry, disabled state and any private permission. It returns the permitted destination, and Ben's browser visits it.
- Save a reporting record. Because Mira wants usage statistics, the application offers a visit record to the queue. Its unique ID,
click-81, lets the reporting program recognize this same visit if work is retried. - Count it once. In one reporting-database transaction, check whether
click-81is in the processed-visit table. If absent, save it and increasegarden24's count for that day and broad region. If already present, leave the count unchanged. The saved ID and count change must commit together. - Explain the report's delay. The background program may crash after counting but before acknowledging queue completion, so the queue can deliver
click-81again. Step 5 prevents a second count. Keep processed visit IDs for as long as the queue can redeliver those records. Mira's report says how far processing has reached—for example, “complete through noon.” A visit count grouped by day is the daily total; the progress timestamp is sometimes called a processing watermark. - Disable the link. Mira disables
garden24. New main-database lookups reject it. Cache removal usually stops old copies sooner, while their unchanged deadlines enforce the 60-second limit.
Clean up without changing old links
- Delete destination and personal details under the retention policy, but keep a used-code marker, called a tombstone. The same
garden24URL must never point to a new owner's site. - Find expired links through an expiry index or scheduled batches. Opening one link must not scan the whole database.
- State backup erasure separately. Refusing a redirect and deleting every stored copy are different operations.
- If reports slow lookups, give their queue, background programs and report database separate capacity. Link state remains in the main mapping database.
10Explain failures, security and cost
If the cache fails
- Up to 58,000 peak lookups/second may reach the database. Test its real fallback capacity.
- Limit simultaneous fallback reads and share one read among requests for the same missing code.
- Gradually refill popular entries. Return a retryable overload error when fallback reads reach the database’s tested request or concurrency limit.
If the main database fails
- A backup may take over. First ensure the old main database can no longer accept writes; losing contact does not prove that it stopped.
- For example, the old writer has permission number 12. The replacement receives number 13, recorded by the storage or coordination system that approves writes.
- Accepting a write must compare its permission number with the saved current number as part of the same indivisible storage operation. A late write carrying 12 is rejected after 13 takes over. A separate earlier check could race the replacement; issuing the number alone is insufficient.
- This rejection of an old writer is called fencing. If the system cannot establish a single safe writer, pause creations instead of letting both claim the same code.
If reporting or batch allocation fails
- Keep valid redirects working when the report queue is full. Record and disclose missed reporting records.
- Retried reporting work uses the same visit ID and the check-and-count transaction described above.
- The baseline does not need a batch allocator. If an alternative relies on prepared batches and exhausts them, pause new creations while existing links continue opening.
Protect and measure the service
- Rate-limit abusive creation and repeated code guesses; review malicious destinations.
- Avoid logging sensitive URL query parameters. If previews are added, isolate the fetcher and block requests into private networks.
- Measure slow redirects, cache use, fallback reads, age of cached state, duplicate code attempts, reporting delays and dropped records. Each measure should reveal whether a stated requirement is being met.
OPTIONAL DESIGN EXTENSIONWhere AI Fits in This Design
Use: prioritize suspicious public links for review
- Input: approved URL/domain features and account abuse signals. Avoid sending sensitive query parameters to routine logs or model requests.
- Output: a risk category, confidence and model version. A result about
garden24can open a review task; it does not prove the link is safe or malicious. - Integration: run a background review after creation. Neither creating a unique link nor opening an existing link waits for a model response.
- Decision: a fixed product rule or authorized reviewer decides whether to disable the link. The model cannot change its destination, reserve aliases or grant private access.
- Fallback: keep ordinary rule-based controls and show review as pending when the model fails. A missing score is not a safety approval.
- Evaluation: measure false alarms, missed abusive links, reviewer workload, delayed results and cost per thousand reviews. Limit retries and daily spending.
A third-party evaluator may visit the URLs it receives. Private links therefore need separate approval or local processing. Google's Web Risk Evaluate documentation gives an example of confidence-based URL assessment and its data processing.
DRAW. CALCULATE. EXPLAIN.
Design bounded link disabling under a viral burst
75 minutes · guided practice
Mira tries to create https://go.example/garden24 with creation request key create-17. Another person wants the same alias, and Mira loses the successful response. Later, the link receives 40,000 redirects/s while the database can handle only 2,000 lookups/s. At noon she disables it.
- Show how the alias conflict and retry are handled without creating a second link.
- Explain what happens when the cache is empty and when a delayed read from a replica tries to refill it after the disable operation.
- Show when cached destinations stop working and how later data cleanup avoids restoring the link.
- Compare generating one code per request with reserving batches of 128 codes.
Compare the worked solution
- Save the
garden24link andcreate-17result in one transaction. The competing alias request gets 409; Mira retriescreate-17and receives the already-created link. - Make simultaneous requests for
garden24share one database read, and cap total fallback traffic at the tested 2,000 lookups/second. Reject excess work with a retryable overload response. - A backup still saying enabled at 12:00:50 cannot create a new 60-second cache lifetime after a noon disable. Keep the deadline established when current database state was checked, and remove cached copies after disabling.
- Count each accepted visit ID once by saving its processed-ID record and daily count change together. If the queue is full, still open the destination and disclose lost statistics.
- Later delete destination data but keep the used-code marker. Begin with random codes and a unique insert; prepared batches need saved assignments and must abandon uncertain unused codes after crashes.
The checkboxes record your own assessment. The worked solution gives you a way to check the reasoning.
RETRIEVE BEFORE YOU REVEAL
Keep the important ideas.
Say the answer aloud, then turn the card over.
CARD 01What happens when someone opens a short URL?
- Read the code from the URL path.
- Find its destination in a usable cache entry or the main database.
- Check expiry, disabled state and any private permission.
- Return the destination so the browser can open it.
CARD 02How do caches stop serving a disabled link within 60 seconds?
- Give each copy a deadline based on when current database state was checked.
- Preserve that deadline when copying entries.
- Remove copies after disabling.
- Never restart the deadline from an outdated backup row.
CARD 03How do retries avoid duplicate links or inflated reports?
- A creation retry returns the result saved for its request key.
- Reporting saves the visit ID and increases the count together, only if that ID was not processed before.
- An expired code is never given to a new destination.
CHECK THE REASONING
Can you explain the choice?
Answer every question, then finish for an immediate score and an explanation for each answer. Aim for 80%, then revisit any gaps.
CLOSE THE LOOP
Explain it without the page.
Summarise the mechanism, one trade-off, and one failure mode. Then mark this chapter complete.