# System Design: [URL shortener name]

> Reference design for a URL shortening service that turns long URLs into
> short codes and redirects visitors at high volume and low latency. It
> covers capacity estimation, the public API, key generation (base62
> encoding, counters, hashes and pre-generated keys, collision handling),
> the data model, the 301 versus 302 redirect trade-off under RFC 9110,
> caching, the click analytics pipeline, abuse and malicious URL protection,
> custom aliases, expiry and multi-region deployment. It follows the
> structure of the System Design Document template and is pre-filled with a
> worked design to adapt. Values marked *Example* are assumptions. Replace
> every `[bracketed]` value and every row marked *Example*.

<!--
Guidance: How to use this template
- Decide first whether links are permanent (marketing, printed QR codes) or
  short-lived (one-time sharing). Permanence drives key length, redirect
  status, expiry and abuse handling.
- Fill the assumptions in 3.1 from your own forecasts; the read-to-write
  ratio and retention period decide almost everything else.
- Keep the key generation comparison in 7.1 even if you choose one option:
  reviewers will ask about collisions and enumeration.
- Treat abuse protection (7.6) as a launch requirement. A shortener without
  it becomes a phishing tool within weeks and gets its domain blocklisted.
-->

## Document control

<!-- Guidance: A design is a proposal until it is approved; record who decided and when. -->

| Field | Value |
|---|---|
| Document ID | [SDD-URL-001] |
| System / feature | [Short link service] |
| Version | [0.1] |
| Status | [Draft / In review / Approved / Implemented / Superseded] |
| Author(s) | [@author] |
| Reviewers | [@reviewer-1], [@reviewer-2] |
| Last updated | YYYY-MM-DD |
| Classification | [Internal] |
| Short domain | [sho.rt] |
| Related | [Capacity estimation worksheet CAP-EST-nnn], [Rate limiter design], [Threat model] |

### Approvals

| Role | Name | Decision | Date |
|---|---|---|---|
| Tech lead | [@name] | [Approved / Changes requested] | YYYY-MM-DD |
| Staff / principal engineer | [@name] | [Decision] | YYYY-MM-DD |
| SRE / operations | [@name] | [Decision] | YYYY-MM-DD |
| Security and trust and safety | [@name] | [Decision] | YYYY-MM-DD |
| Privacy (analytics) | [@name] | [Decision] | YYYY-MM-DD |

## Revision history

<!-- Guidance: Note what changed in the design, not the wording. -->

| Version | Date | Author | Change summary |
|---|---|---|---|
| 0.1 | YYYY-MM-DD | [@name] | First draft for design review |
| 0.2 | YYYY-MM-DD | [@name] | *Example:* Moved from hashed codes to counter ranges with a keyed permutation |
| 0.3 | YYYY-MM-DD | [@name] | *Example:* Default redirect changed from 301 to 302 so analytics and takedowns work |
| 1.0 | YYYY-MM-DD | [@name] | *Example:* Approved |

## Contents

<!-- Guidance: Keep aligned with the headings. -->

1. [Overview](#1-overview)
2. [Requirements](#2-requirements)
3. [Capacity estimation](#3-capacity-estimation)
4. [API design](#4-api-design)
5. [Data model](#5-data-model)
6. [High-level design](#6-high-level-design)
7. [Detailed design](#7-detailed-design)
8. [Scalability and performance](#8-scalability-and-performance)
9. [Reliability, security and operations](#9-reliability-security-and-operations)
10. [Trade-offs and alternatives](#10-trade-offs-and-alternatives)
11. [Delivery plan and risks](#11-delivery-plan-and-risks)
12. [Appendices](#12-appendices)

## 1. Overview

<!-- Guidance: One page that a busy reviewer can read alone: problem, proposed solution in a paragraph, and the decisions that need their attention. -->

### 1.1 Problem statement

[*Example:* Product teams embed long tracking URLs in SMS, printed material
and social posts. Long URLs break in some clients, cannot be updated after
printing and give no click data. Teams currently use three external
shorteners with different terms, no central takedown process and no data
residency control.]

### 1.2 Proposed solution

[*Example:* A first-party shortener on a dedicated short domain. Clients
create links through an authenticated API; the service assigns a 7-character
base62 code from a pre-allocated counter range, passes the destination
through a keyed permutation so codes are not sequential, checks the
destination against URL reputation feeds and stores the mapping in a
replicated key-value store. Visitors hit a stateless redirect tier that
serves most lookups from an in-memory cache and answers with HTTP 302. Each
redirect emits a click event to a message queue; a stream job aggregates
clicks for the statistics API. Links can expire, be disabled by trust and
safety, or carry a custom alias.]

### 1.3 Decisions requested from reviewers

| ID | Question | Recommended option | Section |
|---|---|---|---|
| Q-01 | *Example:* How are codes generated? | Counter ranges per server, encoded in base62 after a keyed permutation | 7.1, 10 |
| Q-02 | *Example:* Which redirect status by default? | 302, with 301 available for links the owner marks permanent | 7.3 |
| Q-03 | *Example:* Same long URL twice: same code or new code? | New code per create request; optional dedupe per owner | 7.2 |
| Q-04 | *Example:* Are expired codes ever reused? | Never | 7.8 |
| [Q-nn] | [Question] | [Option] | [Section] |

## 2. Requirements

<!-- Guidance: Functional requirements describe behaviour; non-functional requirements describe qualities with numbers. Mark what is out of scope so nobody designs for it. -->

### 2.1 Functional requirements

| ID | Requirement | Priority |
|---|---|---|
| FR-01 | *Example:* Create a short link for an http or https URL and return the code | Must |
| FR-02 | *Example:* Redirect a visitor from the short URL to the destination | Must |
| FR-03 | *Example:* Optional custom alias chosen by the owner | Must |
| FR-04 | *Example:* Optional expiry date or click limit | Should |
| FR-05 | *Example:* Owner can disable a link; trust and safety can block it | Must |
| FR-06 | *Example:* Click statistics per link per hour, by country and referrer domain | Should |
| FR-07 | *Example:* Change the destination of a temporary (302) link | Could |
| [FR-nn] | [Requirement] | [Must / Should / Could] |

### 2.2 Non-functional requirements

| ID | Quality | Target | Measured as |
|---|---|---|---|
| NFR-01 | Redirect latency | *Example:* p99 at or below 20 ms at the redirect tier | Server timing |
| NFR-02 | Redirect availability | *Example:* 99.99% monthly | Synthetic probes from three regions |
| NFR-03 | Create latency | *Example:* p99 at or below 300 ms including reputation check | API timing |
| NFR-04 | Durability | *Example:* No acknowledged link is ever lost | Replication factor 3, backup restore test |
| NFR-05 | Unpredictability | *Example:* Codes cannot be enumerated sequentially | Review of 7.1 |
| NFR-06 | Takedown | *Example:* A blocked link stops redirecting within 60 s everywhere | Test against cache TTLs |
| [NFR-nn] | [Quality] | [Target] | [Measurement] |

### 2.3 Out of scope

- [*Example:* link-in-bio pages and landing page builders]
- [*Example:* QR code rendering (handled by the client libraries)]
- [*Example:* user-level tracking across links; analytics are aggregate only]
- [Item deferred, with the release that will take it]

## 3. Capacity estimation

<!-- Guidance: Back-of-the-envelope numbers decide the shape of the design. Show inputs, formulas and results; round generously and say which way you rounded. The Capacity estimation worksheet (`architecture/back-of-envelope-estimation`) explains the method. -->

### 3.1 Assumptions

| Input | Symbol | Value | Source |
|---|---|---|---|
| New links per month | $W$ | *Example:* 100 million | Forecast from current external shorteners |
| Read-to-write ratio | $r$ | *Example:* 100 | Click data from current shorteners |
| Peak-to-average ratio | $k$ | *Example:* 5 | Campaign launches |
| Retention of links | $T$ | *Example:* 5 years | Product policy |
| Stored bytes per link incl. indexes | $s$ | *Example:* 500 B | Schema draft (average URL 200 characters) |
| Replication factor | $N$ | *Example:* 3 | Store configuration |
| Click event size | $e$ | *Example:* 200 B | Event schema |
| Usable redirects per instance | $q$ | *Example:* 5 000 req/s at 60% CPU | Load test |

### 3.2 Request rates

A 30-day month has 2 592 000 seconds:

$$
Q_{w,avg} = \frac{10^{8}}{2\,592\,000} \approx 38.6 \ \text{creates/s}, \qquad Q_{w,peak} = 38.6 \times 5 \approx 193 \ \text{creates/s}
$$

$$
Q_{r,avg} = 38.6 \times 100 \approx 3\,858 \ \text{redirects/s}, \qquad Q_{r,peak} = 3\,858 \times 5 \approx 19\,290 \ \text{redirects/s}
$$

Per day that is about 3.33 million creates and 333 million redirects.

### 3.3 Storage and key space

$$
L = 10^{8} \times 12 \times 5 = 6 \times 10^{9} \ \text{links}, \qquad
S = 6 \times 10^{9} \times 500 \ \text{B} = 3 \ \text{TB}, \qquad S \times N = 9 \ \text{TB}
$$

Base62 key space for code length $\ell$ is $62^{\ell}$:

$$
62^{6} \approx 5.68 \times 10^{10}, \qquad 62^{7} \approx 3.52 \times 10^{12}
$$

Six characters would be 10.6% full after five years, which makes random
codes collide often and leaves little room for growth. Seven characters are
$(6 \times 10^{9}) / (3.52 \times 10^{12}) \approx 0.17\%$ full after five years.

### 3.4 Cache and analytics

Caching the hot 20% of a day's redirects as an upper bound:

$$
C = 3.33 \times 10^{8} \times 0.2 \times 500 \ \text{B} \approx 33 \ \text{GB}
$$

Click events:

$$
E_{day} = 3.33 \times 10^{8} \times 200 \ \text{B} \approx 67 \ \text{GB/day} \approx 24 \ \text{TB/year raw}
$$

### 3.5 Summary

| Quantity | Estimate | Design consequence |
|---|---|---|
| Creates | 38.6/s average, 193/s peak | Small write tier; reputation check fits in budget |
| Redirects | 3 858/s average, 19 290/s peak | Stateless redirect tier with cache, 6 instances across 3 zones |
| Links after 5 years | 6 billion, 3 TB (9 TB replicated) | Partitioned key-value store keyed by code |
| Code length | 7 base62 characters | 0.17% of key space used after 5 years |
| Cache | about 33 GB | One small in-memory cache cluster per region |
| Click events | about 67 GB/day | Queue plus stream aggregation; raw events kept 90 days |
| Redirect egress | $19\,290 \times 500$ B $\approx 9.6$ MB/s at peak | Not a constraint |

## 4. API design

<!-- Guidance: Define the contract first: resources, methods, request and response shapes, errors, idempotency, pagination, rate limits and versioning. Use RFC 9457 problem details for errors. -->

### 4.1 Endpoints

| Method and path | Purpose | Auth |
|---|---|---|
| `POST /v1/links` | Create a link (optional alias, expiry, redirect type) | API key or OAuth token |
| `GET /v1/links/{code}` | Read link metadata | Owner |
| `PATCH /v1/links/{code}` | Change destination (302 links only), expiry or status | Owner |
| `DELETE /v1/links/{code}` | Disable a link (code stays reserved) | Owner |
| `GET /v1/links/{code}/stats?from=&to=&granularity=hour` | Aggregated clicks | Owner |
| `GET /{code}` on the short domain | Redirect | Public |
| `POST /v1/reports` | Report an abusive link | Public, rate limited |

### 4.2 Request and response examples

Create a link, then follow it from the short domain:

```http
POST /v1/links HTTP/1.1
Authorization: Bearer [token]
Idempotency-Key: 6f1c2a9e-4b7d-4c1e-9a55-0d3f2b8e7c11
Content-Type: application/json

{"url": "https://www.example.com/spring-sale?utm_source=sms&utm_campaign=s26", "alias": null, "expires_at": "2027-03-31T23:59:59Z", "redirect": 302}
```

```http
HTTP/1.1 201 Created
Location: https://api.sho.rt/v1/links/k7Qx2Lm
Content-Type: application/json

{"code": "k7Qx2Lm", "short_url": "https://sho.rt/k7Qx2Lm", "url": "https://www.example.com/spring-sale?utm_source=sms&utm_campaign=s26", "redirect": 302, "expires_at": "2027-03-31T23:59:59Z", "status": "active"}
```

```http
GET /k7Qx2Lm HTTP/1.1
Host: sho.rt

HTTP/1.1 302 Found
Location: https://www.example.com/spring-sale?utm_source=sms&utm_campaign=s26
Cache-Control: private, max-age=60
Referrer-Policy: strict-origin-when-cross-origin
```

### 4.3 Errors and idempotency

| Situation | Status | Problem type |
|---|---|---|
| Destination not http or https, or longer than 2 048 characters | 400 | `invalid-url` |
| Destination flagged by reputation check | 422 | `destination-blocked` |
| Alias already taken or reserved | 409 | `alias-unavailable` |
| Create rate limit exceeded | 429 | `rate-limited` with `Retry-After` |
| Unknown code on the short domain | 404 | HTML page, no redirect |
| Expired or disabled link | 410 | HTML page explaining the link is no longer active |
| Blocked by trust and safety | 410 (451 when blocked for a legal reason) | Warning page without the destination link |

A retried `POST` with the same `Idempotency-Key` and body within 24 hours
returns the original response; with a different body it returns 422. Keys
are stored with the owner ID so two owners cannot collide.

## 5. Data model

<!-- Guidance: Choose storage by access pattern, not by habit. Show entities, keys, partitioning and the queries each store must answer. -->

### 5.1 Entities

Links are the core record; click aggregates and abuse reports hang off the code.

```mermaid
erDiagram
  OWNER {
    string owner_id PK
    string plan
    string status
  }
  LINK {
    string code PK
    string long_url
    string owner_id FK
    int redirect_status
    datetime created_at
    datetime expires_at
    int click_limit
    string status
    string url_hash
  }
  CLICK_HOURLY {
    string code PK
    datetime hour PK
    bigint clicks
    bigint unique_estimate
    string top_countries
  }
  ABUSE_REPORT {
    string report_id PK
    string code FK
    string reason
    string state
    datetime created_at
  }
  OWNER ||--o{ LINK : owns
  LINK ||--o{ CLICK_HOURLY : "aggregated into"
  LINK ||--o{ ABUSE_REPORT : "reported in"
```

### 5.2 Storage choices

| Data | Store | Key | Main queries | Why |
|---|---|---|---|---|
| Links | *Example:* Replicated key-value or wide-column store, partitioned by code | `code` | Get by code (redirect), conditional insert | Single-key reads at high rate, horizontal scale |
| Owner index | Same store, secondary table | `owner_id`, `created_at` desc | List an owner's links | Avoids scatter-gather |
| Dedupe index (optional) | Same store | `owner_id` + `url_hash` | Find existing code for a URL | Only when dedupe is enabled per owner |
| Click aggregates | Columnar analytics store | `code`, `hour` | Range by time per code | Cheap scans and compression |
| Raw click events | Message queue, then object storage for 90 days | Time | Reprocessing, abuse investigations | Not on the redirect path |
| Abuse reports | Relational database | `report_id` | Queue for reviewers | Small, workflow-heavy |

The link record is immutable except for `status`, `expires_at` and, for 302
links, `long_url`. Every change is written to an audit table with the actor.

## 6. High-level design

<!-- Guidance: One diagram that shows every component on the request paths and where state lives. Name components by responsibility. -->

```mermaid
flowchart LR
  visitor([Visitor]) --> edge[Edge / CDN, TLS]
  edge --> redir[Redirect service]
  redir --> cache[(Regional cache)]
  redir --> kv[(Link store, replicated)]
  redir --> q[[Click event queue]]
  q --> stream[Stream aggregator]
  stream --> olap[(Click aggregates)]
  client([API client]) --> api[Link API]
  api --> rl[Rate limiter]
  api --> rep[URL reputation check]
  api --> keys[Key allocator]
  api --> kv
  ts[Trust and safety console] --> api
  rescan[Periodic rescanner] --> kv
  rescan --> rep
  api --> olap
```

| Component | Responsibility | Scales by |
|---|---|---|
| Edge / CDN | TLS termination, DDoS absorption, optional short-TTL caching of redirects | Provider |
| Redirect service | Look up code, check status and expiry, answer redirect, emit click event | Stateless instances |
| Regional cache | Hold hot code-to-URL mappings and negative entries for unknown codes | Memory per node |
| Link store | Durable mapping, conditional inserts for codes and aliases | Partitions by code |
| Link API | Validate, authorise, check reputation, allocate code, write | Stateless instances |
| Key allocator | Hand out counter ranges to API instances | One small replicated service |
| Click event queue and aggregator | Decouple analytics from redirects | Partitions |
| Periodic rescanner | Re-check destinations against reputation feeds | Batch workers |

## 7. Detailed design

<!-- Guidance: Go deep on the parts that are new, risky or contested. Show the sequence of calls and the failure behaviour of each step. -->

### 7.1 Key generation

Base62 uses the alphabet `0-9a-zA-Z` (digits are values 0 to 9, lower case
10 to 35, upper case 36 to 61). Encoding repeatedly divides the number by 62
and maps remainders to characters, most significant first. For example
$125 = 2 \times 62 + 1$ encodes as `21`, and
$11\,157 = 2 \times 62^{2} + 55 \times 62 + 59$ encodes as `2TX`.

| Option | How it works | Collisions | Enumeration | Coordination | Notes |
|---|---|---|---|---|---|
| Hash of URL | Truncate a hash (for example SHA-256) of the URL, plus salt, to 7 characters | Possible; must check and retry with a new salt | Not sequential | None | Same URL gives same code unless salted per request |
| Random code | Draw 7 random base62 characters from a CSPRNG | Possible; check and retry | Not sequential | None | Simple; collision rate rises with occupancy |
| Global counter | Single sequence, encode in base62 | None | Sequential, trivially enumerable | Every create hits the counter | Bottleneck and privacy problem |
| Counter ranges | Allocator hands each API instance a block of IDs; instance encodes locally | None | Sequential unless permuted | One call per block | Gaps when an instance dies holding a block |
| Pre-generated keys | Offline job fills a table of unused random codes; API claims them | None at claim time | Not sequential | Claim must be atomic | Extra table and refill job |

**Chosen:** counter ranges (blocks of 1 000 000 IDs) with a keyed bijective
permutation of the ID space $[0, 62^{7})$ before encoding, for example a
format-preserving encryption mode such as FF1 from NIST SP 800-38G. The
permutation makes codes look random while preserving uniqueness; it is an
obfuscation against casual enumeration, not access control, so private
destinations still need authentication at the destination.

At the peak create rate of 193/s, one block lasts at least
$10^{6} / 193 \approx 5\,181$ s (about 86 minutes) even if a single
instance served all creates. Losing an unused block wastes IDs, not
correctness: the key space is 0.17% used after five years.

**Collision handling:** every write is a conditional insert ("put if
absent") on `code`. With counter ranges a conflict indicates a bug or an
alias clash, so the API raises an alert and allocates the next ID. With
random or hashed codes the expected retries per insert are about the
occupancy ratio, so 0.17% at five years. The birthday bound shows why the
check is mandatory rather than optional: for $n$ random codes in a space of
size $M$, the chance of at least one collision is about
$1 - e^{-n^{2}/(2M)}$, which is effectively 1 for $n = 6 \times 10^{9}$
and $M = 3.52 \times 10^{12}$.

### 7.2 Create flow

```mermaid
sequenceDiagram
  participant C as API client
  participant A as Link API
  participant R as Rate limiter
  participant U as Reputation check
  participant K as Link store
  C->>A: POST /v1/links (Idempotency-Key)
  A->>R: check owner and IP limits
  R-->>A: allowed
  A->>A: validate scheme, length, normalise host
  A->>U: lookup destination
  alt flagged
    U-->>A: malicious
    A-->>C: 422 destination-blocked
  else clean or unknown
    U-->>A: clean
    A->>A: next ID from local block, permute, base62
    A->>K: put if absent (code, url, owner)
    K-->>A: ok
    A-->>C: 201 Created (short_url)
  end
```

Normalisation lower-cases the scheme and host and removes default ports; it
never rewrites the path or query, because those are meaningful to the
destination. Dedupe (Q-03) is per owner and off by default, since two
campaigns often need separate statistics for the same URL.

### 7.3 Redirects: 301 versus 302

RFC 9110 defines 301 (Moved Permanently, section 15.4.2) and 308 (Permanent
Redirect, 15.4.9) as permanent and lists them among the status codes that
are heuristically cacheable; 302 (Found, 15.4.3) and 307 (Temporary
Redirect, 15.4.8) are temporary. 307 and 308 require the client to keep the
request method; for a shortener visitors almost always send GET, so the
difference matters little.

| Aspect | 301 / 308 permanent | 302 / 307 temporary |
|---|---|---|
| Browser caching | Browsers may cache the redirect for a long time | Revalidated per visit unless cache headers allow |
| Load on the service | Lower after the first visit | Every visit reaches the edge or service |
| Click analytics | Repeat visits invisible | Every visit counted |
| Changing destination or takedown | Cached copies keep redirecting | Takes effect at next visit or cache expiry |
| Search engine treatment | Signals a permanent move | Signals a temporary move |

**Chosen:** 302 by default with `Cache-Control: private, max-age=60`, so
NFR-06 (takedown within 60 s) holds and analytics stay complete. Owners can
opt into 301 for links they declare permanent and will never edit; such
links cannot change destination, and takedown relies on the warning page
reaching visitors whose browsers have not cached the redirect.

### 7.4 Caching

- **Redirect tier cache:** read-through cache keyed by code with TTL 60 s
  for active links. Status changes publish an invalidation event so blocked
  links leave every regional cache within seconds, with the TTL as the
  backstop.
- **Negative caching:** unknown codes are cached for 30 s to absorb scanning
  bots without hitting the store.
- **Edge caching:** when enabled, the redirect carries
  `Cache-Control: max-age=60` instead of `private` (a `private` response
  must not be stored by a shared cache), so the CDN may cache 302 responses
  for the same 60 s, which cuts origin load during campaign spikes. Click counting then happens
  from edge logs for cached hits, or edge caching is disabled for links with
  analytics enabled; choose one and record it in 12.1.
- **Hit ratio:** with a 90% cache hit ratio the store serves about
  $19\,290 \times 0.1 \approx 1\,929$ reads/s at peak.

### 7.5 Analytics pipeline

The redirect service writes a click event to a local buffer and returns the
redirect; a background sender publishes batches to the click event queue
(see Message queue design (`architecture/design-message-queue`)). Losing
a few events during a crash is accepted: analytics are at-least-once from
the queue onwards and best effort before it.

| Stage | Processing | Output |
|---|---|---|
| Redirect service | Build event: code, timestamp, country from IP, referrer host, user agent class, truncated IP | Event batch |
| Queue | Partitioned by code, retained 7 days | Durable stream |
| Stream aggregator | Count per code per hour; unique visitors estimated with HyperLogLog sketches; deduplicate by event ID | Hourly rows |
| Columnar store | Hourly aggregates kept for the life of the link | Statistics API |
| Object storage | Raw events kept 90 days, then deleted | Reprocessing and investigations |

Privacy: no cookies are set on the short domain; IP addresses are truncated
before storage and never exposed through the API; aggregates below 10
clicks per bucket are shown as "fewer than 10" to limit re-identification.

### 7.6 Abuse and malicious URL protection

| Control | When | Effect |
|---|---|---|
| Scheme allow list (http, https only) | Create | Blocks `javascript:`, `data:` and `file:` destinations |
| Reputation lookup against a URL threat feed (for example Google Safe Browsing, subject to its terms) and an internal block list | Create, and rescans at 1 h, 24 h and weekly | Flags malware, phishing and unwanted software |
| Rate limits per account, per IP and per destination domain | Create | Limits bulk generation; see Rate limiter design (`architecture/design-rate-limiter`) |
| Self-referencing and chained shorteners refused | Create | Prevents redirect loops and laundering through other shorteners |
| Warning interstitial | Redirect, for flagged or newly created links from unverified accounts | Visitor sees destination host and a warning |
| Public report endpoint and trust and safety console | Any time | Disable, block or restore with audit trail |
| No server-side fetch of the destination outside an isolated scanner | Always | Prevents server-side request forgery from the API tier |

A blocked link returns a warning page that does not render the destination
as a clickable link. Repeat offenders have all their links disabled.

### 7.7 Custom aliases

- Allowed characters `[A-Za-z0-9_-]`, length 4 to 30 (*Example*); case is
  significant, matching generated codes.
- A reserved list blocks paths used by the service (`api`, `login`,
  `help`), brand names and offensive words.
- Aliases share the code key space and use the same conditional insert, so
  an alias can never shadow a generated code.
- Short aliases (4 to 6 characters) are limited to paid plans to reduce
  squatting.

### 7.8 Expiry and deletion

Expiry is checked at read time (`expires_at` or click limit reached) and the
service returns 410. A daily job marks expired links and removes them from
caches. Codes are never reused: a reused code would send old printed or
bookmarked links to a new destination, which is both a safety and a fraud
risk. Deleting a link removes the destination after 30 days but keeps the
code tombstoned.

### 7.9 Multi-region

- **Reads:** each region has a redirect tier, cache and a full replica of
  the link store; redirects never leave the region.
- **Writes:** creates are accepted in any region. Counter blocks are
  allocated per region from disjoint ranges, so regions never collide on
  generated codes. Aliases use a conditional insert in a strongly
  consistent global table, accepting higher create latency for aliases only.
- **Replication lag:** a link created in region A and opened in region B
  before replication finishes would miss. On a local miss the redirect
  service asks the home region encoded in the code's block metadata before
  returning 404, and caches the result.

## 8. Scalability and performance

<!-- Guidance: Show how the design meets the numbers in section 3 and where it will break first. -->

| Concern | Approach | Limit before redesign |
|---|---|---|
| Redirect rate | Stateless tier: $\lceil 19\,290 / 5\,000 \rceil = 4$ instances, $\lceil 4 \times 3/2 \rceil = 6$ across three zones | *Example:* about 10 times growth by adding instances |
| Hot links | Cache plus optional edge caching | One link at campaign peak |
| Store growth | Partitioned by code, 9 TB replicated after 5 years | Tens of TB without change |
| Create rate | Local ID blocks, no per-create coordination | Reputation service quota first |
| Analytics volume | Queue partitions and hourly aggregation | Raw retention cost |

Latency budget for a redirect (target p99 20 ms at the service):

| Step | Budget |
|---|---|
| Parse and validate code | 0.1 ms |
| Cache lookup (same zone) | 1 ms |
| Store lookup on cache miss (10% of requests) | 10 ms |
| Status and expiry checks, build response | 0.2 ms |
| Enqueue click event (local buffer) | 0.1 ms |
| Headroom | 8.6 ms |

## 9. Reliability, security and operations

<!-- Guidance: Name the failure modes, the threats and the signals operators watch. Each mitigation should be testable. -->

### 9.1 Failure modes

| Failure | Impact | Detection | Mitigation |
|---|---|---|---|
| Cache cluster down | Store load rises ten times | Cache error rate | Store sized for 50% hit ratio during incidents; request coalescing |
| Store partition unavailable | Some codes cannot resolve | Error rate per partition | Replicas in three zones; serve stale cache entries for up to 1 h |
| Key allocator down | No new blocks | Allocation errors | Instances hold two blocks; creates continue for hours |
| Reputation service down | Creates cannot be checked | Timeout rate | Create with `pending_review` status and warning interstitial until checked |
| Analytics queue down | Click data delayed | Buffer depth | Local buffer with bounded size; drop oldest with a counter |
| [Failure] | [Impact] | [Signal] | [Mitigation] |

### 9.2 Security and abuse

| Threat | Control |
|---|---|
| Phishing and malware distribution | 7.6 controls, rescans, interstitials, fast takedown |
| Enumeration of private links | Keyed permutation, 7-character codes, negative caching, rate limits on 404s |
| Open redirect used against the parent brand | Separate short domain, never the main application domain |
| Account takeover to edit destinations | Strong authentication for owners, audit log, notification on destination change |
| Destination change after approval (bait and switch) | Destination edits trigger a new reputation check and interstitial for 24 h |
| [Threat] | [Control] |

Record these in the threat model (`security/threat-model`).

### 9.3 Observability

| Signal | Metric or log | Alert threshold |
|---|---|---|
| Redirect latency | p99 per region | Above 20 ms for 10 min |
| Redirect errors | 5xx share | Above 0.1% for 5 min |
| 404 rate | Unknown codes per second | Tripling vs. 7-day baseline (scanning) |
| Blocked creates | Share flagged by reputation | Sudden rise (campaign of abuse) |
| Takedown latency | Time from block to last served redirect | Above 60 s |

### 9.4 Cost estimate

| Item | Unit cost | Quantity | Monthly cost |
|---|---|---|---|
| Redirect and API instances | [USD per instance-month] | *Example:* 6 redirect + 3 API per region x 2 regions | [USD] |
| Link store | [USD per TB-month and per million reads] | 9 TB after 5 years | [USD] |
| Cache | [USD per node-month] | *Example:* 3 nodes per region | [USD] |
| Analytics queue, stream and store | [USD] | 67 GB/day raw | [USD] |
| Reputation feed | [Licence or quota cost] | 3.3 million lookups/day plus rescans | [USD] |
| **Total** | | | **[USD]** |

## 10. Trade-offs and alternatives

<!-- Guidance: For each major decision list the options, what each costs and why you chose one. Reviewers will propose the alternatives anyway; answer them here. -->

| Decision | Options | Chosen | Why | What we give up |
|---|---|---|---|---|
| Code generation | Hash; random; global counter; counter ranges; pre-generated | Counter ranges with permutation | No collisions, no per-create coordination | Permutation key is a secret to manage |
| Code length | 6; 7; 8 characters | 7 | 0.17% full after 5 years | One more character than the minimum |
| Default redirect | 301; 302; 307 | 302 | Analytics, edits and takedown work | More traffic than permanent redirects |
| Store | Relational; key-value | Key-value partitioned by code | Single-key access at scale | Secondary queries need extra tables |
| Analytics | Synchronous counter update; async events | Async events | Redirect latency unaffected | Counts lag by minutes |
| Code reuse after expiry | Reuse; never | Never | Old links never go to a new destination | Key space slowly consumed |
| [Decision] | [Options] | [Chosen] | [Why] | [Cost] |

## 11. Delivery plan and risks

<!-- Guidance: How the design reaches production safely, and what could stop it. -->

### 11.1 Milestones

| Milestone | Scope | Exit criterion | Target date |
|---|---|---|---|
| M1 | Create and redirect in one region, internal users | p99 redirect under 20 ms in load test at 2x peak | YYYY-MM-DD |
| M2 | Abuse controls, reputation checks, trust and safety console | Red-team test of phishing creation blocked | YYYY-MM-DD |
| M3 | Analytics pipeline and statistics API | Counts within 1% of redirect logs | YYYY-MM-DD |
| M4 | Second region, migration of links from external shorteners | Redirect parity test for all migrated codes | YYYY-MM-DD |

### 11.2 Rollout and rollback

[*Example:* Launch with internal teams only, then allow external accounts by
plan tier. Migrated links from external shorteners are imported with their
original codes on a separate path prefix and verified one by one before the
old shortener's domain is pointed at the new service.]

### 11.3 Risks

| ID | Risk | Likelihood | Impact | Mitigation | Owner |
|---|---|---|---|---|---|
| R-01 | *Example:* Short domain added to blocklists after abuse | Medium | High | 7.6 controls, monitoring of blocklist status, abuse contact published | [@name] |
| R-02 | *Example:* Permutation key leaked, codes become predictable | Low | Medium | Key in a secrets manager; codes remain unique even if predictable | [@name] |
| R-03 | *Example:* Analytics privacy complaint | Low | Medium | Aggregate-only data, truncated IPs, documented retention | [@name] |
| [R-nn] | [Risk] | [L / M / H] | [L / M / H] | [Mitigation] | [@name] |

## 12. Appendices

<!-- Guidance: Supporting detail, glossary and open questions. -->

### 12.1 Open questions

| ID | Question | Owner | Due |
|---|---|---|---|
| OQ-01 | *Example:* Edge caching of redirects, or complete click counts? | [@name] | YYYY-MM-DD |
| OQ-02 | *Example:* Do enterprise owners need their own branded short domains? | [@name] | YYYY-MM-DD |
| [OQ-nn] | [Question] | [@name] | YYYY-MM-DD |

### 12.2 Glossary

| Term | Meaning |
|---|---|
| Code | The short path segment that identifies a link |
| Alias | A code chosen by the owner instead of generated |
| Base62 | Encoding with 62 symbols: ten digits and two cases of 26 letters |
| Keyed permutation | Reversible mapping of the ID space controlled by a secret key |
| Negative caching | Caching the fact that a key does not exist |
| Interstitial | Page shown before redirecting, with a warning or confirmation |
| [Term] | [Meaning] |

### 12.3 References

| Reference | Use |
|---|---|
| RFC 9110 HTTP Semantics (June 2022), sections 15.4.2, 15.4.3, 15.4.8, 15.4.9 | Redirect status codes and cacheability |
| RFC 9457 Problem Details for HTTP APIs | Error format |
| NIST SP 800-38G, format-preserving encryption (FF1) | Keyed permutation option in 7.1 |
| URL reputation provider documentation and terms | 7.6 |
| Capacity estimation worksheet (`architecture/back-of-envelope-estimation`) | Method for section 3 |
| System design document (`architecture/system-design-document`) | Parent structure |

## Standard and licence note

Based on common industry practice for URL shortening services and on the
HTTP specifications cited in 12.3. This is a reference design to adapt, with
illustrative numbers. Original text written for this collection; no text
from the cited specifications is reproduced. Check the current versions of
the RFCs and the terms of any reputation service before use. Template
licence: MIT.
