๐ Design a URL Shortener (bit.ly style)¶
A read-heavy, write-light system whose real problems are unique short-code allocation, a sub-50 ms cached redirect path, counting a billion clicks a day without hot rows, and not becoming a phishing relay. Follows the framework and reuses its worked numbers.
Table of Contents¶
- Requirements
- Estimation
- API
- Data Model
- High-Level Design
- Deep Dives
- Failure Modes and Scaling
- Observability, Rollout and Cost
- Alternatives and Trade-offs
- Staff-Level Follow-up Questions
- Common Mistakes
1. Requirements¶
Functional¶
| # | Requirement |
|---|---|
| F1 | Create a short link for a long URL (anonymous or authenticated). |
| F2 | Redirect GET /{code} to the long URL. |
| F3 | Optional custom alias (/spring-sale), unique globally. |
| F4 | Optional expiry (expires_at); expired links stop redirecting. |
| F5 | Owners can disable or delete a link; the change takes effect quickly. |
| F6 | Click analytics: total clicks and time series per link (hour/day), with country and referrer breakdown as a stretch. |
| F7 | Abuse handling: block malicious destinations, rate-limit creation, support takedown. |
Non-functional (numeric targets)¶
| Dimension | Target |
|---|---|
| Scale | 100 M new links/day, 1 B redirects/day (10:1), 5-year retention |
| Redirect latency | p99 < 50 ms server-side (excludes client network); cache-hit p99 < 10 ms |
| Redirect availability | 99.99% (about 52 min/year). Redirect is the product. |
| Create latency / availability | p99 < 300 ms; 99.9% |
| Durability | A created link is never lost once the API returned 2xx |
| Uniqueness | A code maps to exactly one destination at any time (strong, per key) |
| Analytics freshness | 95% of clicks visible within 2 minutes; counts may be approximate (see 6.3) |
| Takedown propagation | A blocked link stops redirecting everywhere within 60 s |
Explicit non-goals¶
- Link previews, QR codes, A/B or per-device destinations, real-time click streaming, billing, UI.
- Exact-once click counting and bot-proof analytics (we filter obvious bots, not guarantee).
2. Estimation¶
Assumptions (match the framework's worked example): 100 M creates/day, 10:1 read:write, 500 B per link record, 5 years, peak = 3x average.
| Quantity | Arithmetic | Result |
|---|---|---|
| Create QPS | 100 M / 86,400 | 1,157 โ 1.2k avg, ~3.5k peak |
| Redirect QPS | 1 B / 86,400 | 11,574 โ 12k avg, ~35k peak |
| Links over 5 years | 100 M ร 365 ร 5 | 182.5 B |
| Link storage (logical) | 182.5 B ร 500 B | 91 TB |
| Owner index | 182.5 B ร 50 B | โ 9 TB, so ~100 TB logical |
| Replicated | 100 TB ร 3 | ~300 TB |
| Storage nodes | 300 TB / ~2 TB usable per node | ~150 nodes |
| Key space | 62^7 = 3.52 ร 10^12 | 182.5 B / 3.52 T = 5.2% full after 5 years |
| Redirect response | ~400 B incl. headers ร 35k/s | 14 MB/s โ 112 Mbps (trivial) |
| Cache hot set | 20% of the last 7 days of links: 0.2 ร 700 M = 140 M entries ร ~200 B | ~28 GB (about 2x with Redis overhead, so ~60 GB) |
| Store reads at 90% hit ratio | 10% ร 35k | ~3.5k/s peak |
| Click events | 1 B/day ร ~100 B | 100 GB/day; 35k events/s peak = 3.5 MB/s |
| Raw click retention | 100 GB ร 30 days | 3 TB |
Decisions this implies¶
- Reads dominate and are cacheable: a cache tier absorbs about 90% so the store sees ~3.5k reads/s at peak. The store is sized by capacity (300 TB), not QPS.
- 300 TB replicated means a horizontally partitioned key-value store keyed by code from day one. A single Postgres is not an option.
- 7 characters is enough; the space is only 5.2% used in 5 years, so random codes with a conditional insert are viable (expected 1.055 attempts at the end).
- Click events are 35k/s peak but tiny in bytes: a log (Kafka) plus stream aggregation, never a database write per redirect.
- Creates are only ~3.5k/s peak, so a single write region is acceptable; redirects are served from every region.
3. API¶
Auth: creation and management use Authorization: Bearer <token> (or an API key); anonymous creation is allowed at a lower rate limit. Redirect is unauthenticated.
3.1 Create¶
POST /v1/links
Authorization: Bearer <token>
Idempotency-Key: 7c1d6f0e-5a0b-4c53-9d0a-2f6a6e8b1c11
Content-Type: application/json
{
"long_url": "https://example.com/products/42?utm_source=mail",
"custom_alias": "spring-sale",
"expires_at": "2027-03-01T00:00:00Z"
}
HTTP/1.1 201 Created
{
"code": "spring-sale",
"short_url": "https://sho.rt/spring-sale",
"expires_at": "2027-03-01T00:00:00Z"
}
- Idempotency: the pair
(owner_id, Idempotency-Key)is stored for 24 h with a hash of the request body. A retry with the same key and body returns the original201and the samecode; the same key with a different body returns422. Without this, a client timeout retry mints a second code. 409 Conflictif the alias is taken.400for invalid URL (scheme must be http/https, max length 2,048, not pointing at our own domain to avoid loops).422if the destination is blocked by reputation.429when rate limited, withRetry-After.- Aliases: 3 to 32 chars of
[A-Za-z0-9_-], case sensitive, not reserved (api,admin, ...).
3.2 Redirect¶
GET /Ab3xK9q
HTTP/1.1 302 Found
Location: https://example.com/products/42?utm_source=mail
Cache-Control: private, max-age=0
404for unknown,410 Gonefor expired or deleted,451for legally or abuse-blocked. A blocked link may instead render an interstitial warning page.- 302, not 301: a 301 is cached by browsers indefinitely, so clicks never reach us (analytics lost) and a takedown cannot take effect for those clients. See 6.2.
3.3 Management and stats¶
GET /v1/links/{code} # metadata
PATCH /v1/links/{code} {"status":"disabled"} # disable / change expiry (If-Match: <version>)
DELETE /v1/links/{code} # soft delete, idempotent
GET /v1/links?limit=50&cursor=eyJjIjoiMjAyNi0x... # owner's links, newest first
GET /v1/links/{code}/stats?from=2026-10-01&to=2026-10-08&granularity=day
Stats response: {"code": "spring-sale", "total_clicks": 18342, "as_of": "2026-10-08T10:14:00Z", "series": [{"t": "2026-10-07", "clicks": 2210}]}.
- List pagination is a cursor (opaque, base64 of
(created_at, code)), never offset: the owner index is large and changes while paging. PATCHuses optimistic concurrency (version); the destination URL is immutable by default (changing it silently is a phishing vector, so require delete and recreate unless the owner is verified).
4. Data Model¶
Access patterns: (1) get link by code (hot, 12k/s); (2) insert if code absent; (3) list links by owner; (4) increment and read counts by code and time bucket.
| Table | Key | Attributes | Notes |
|---|---|---|---|
links |
PK: code (hash-partitioned) |
long_url, owner_id, created_at, expires_at, status (active/disabled/blocked), redirect_type, version |
~500 B. Conditional put attribute_not_exists(code) is the uniqueness guarantee. |
links_by_owner (index) |
PK owner_id, sort created_at DESC, code |
code, status |
~50 B. Eventually consistent index is fine. |
idempotency |
PK (owner_id, idem_key) |
request_hash, code, created_at |
TTL 24 h. Written with the link in one conditional batch where the store allows, else link first then idempotency row (see failure modes). |
click_counts |
PK code, sort bucket_start (hour) |
clicks |
Written only by the aggregator, with upserts that replace (6.3). |
click_events |
columnar OLAP table, ordered by (code, ts) |
ts, code, country, referrer, ua_class, event_id |
30-day retention, source for breakdowns. |
Partition key choice: code is random, so hash partitioning spreads writes evenly with no sequential hot end. The only skew is read skew on viral links, handled by caching (6.2), not by partitioning.
Why a partitioned key-value store (DynamoDB/Cassandra style) over SQL: the dominant query is a point lookup by primary key; there are no joins; uniqueness is single-key (conditional put); capacity is 300 TB. Sharded MySQL/Postgres works too (shard by hash(code), unique index per shard) but costs resharding tooling and operational effort at ~150 nodes. If the team already runs sharded MySQL well, that is a defensible v1.
Expiry: store expires_at and check it at read time in the redirect service. Use the store's TTL (or a background reaper) only to reclaim space; TTL deletion is lazy and can lag hours to days, so it must never be the enforcement mechanism.
5. High-Level Design¶
flowchart LR
C["Client / Browser"] --> CDN["Edge / L7 Load Balancer"]
CDN --> RS["Redirect Service (stateless)"]
RS --> L1["In-process LRU (5-10 s TTL)"]
RS --> RC["Redis Cache Cluster"]
RC -. "miss" .-> KV[("Links KV Store (partitioned by code)")]
RS -- "click event (async, buffered)" --> K["Kafka: clicks"]
C --> AG["API Gateway + Rate Limiter"]
AG --> CS["Create / Manage Service"]
CS --> KV
CS --> SC["URL Reputation Check"]
CS --> RC
K --> AGG["Stream Aggregator"]
AGG --> CC[("click_counts")]
AGG --> OLAP[("OLAP: click_events")]
AG --> ST["Stats Service"]
ST --> CC
ST --> OLAP
Write path (create):
- Gateway authenticates, applies per-user and per-IP rate limits (token bucket in Redis), validates the body.
- Create service checks the idempotency record; a hit returns the stored result.
- Reputation check (blocklist lookup plus async deep scan): known-bad returns
422. Unknown URLs are accepted and scanned asynchronously (see the abuse rows in section 7). - If a custom alias is given, use it as
code; otherwise generate a random 7-char Base62 code. - Conditional put into
links. On conflict: alias returns409; generated code retries with a new random code (up to 5 attempts, then 8 chars). - Write the
idempotencyrow andlinks_by_ownerentry, warm the cache with the new link (write-through, TTL 24 h), return201.
Read path (redirect):
- LB routes to any redirect instance (stateless, no stickiness).
- Look in the in-process LRU (hot links, TTL 5 to 10 s), then Redis. On a miss, read the store by
code, populate Redis. - Evaluate:
status == activeandnow < expires_at. Otherwise return 410/451/404. - Respond
302immediately. Emit the click event after deciding the response, to a bounded local buffer that flushes to Kafka in batches. If the buffer is full or Kafka is down, drop the event and increment a counter; never block or fail the redirect for analytics. - Aggregator consumes
clicks, produces per-minute then per-hour counts and OLAP rows.
6. Deep Dives¶
6.1 Generating unique short codes¶
Problem: every create needs a code unique across 182 B links, with no coordination bottleneck at 3.5k/s peak, codes should not be trivially enumerable (a private doc link shared by "obscurity"), and custom aliases share the same namespace.
Options
| Option | How | Problem |
|---|---|---|
| A. Hash and truncate | base62(sha256(long_url))[:7] |
Collisions after truncation need a retry scheme anyway; same URL from two users maps to one code (analytics and expiry collide); salting to fix it turns it into option B. |
| B. Random code + conditional insert | Draw 7 random Base62 chars, PUT if absent |
Retries as fill grows; needs a single uniqueness authority (the store's key). |
| C. Counter with ranges | Allocator hands each app server a block (e.g. 1 M ids); code = base62(scramble(n)) |
Allocator is a coordination point; sequential ids are enumerable unless scrambled; lost blocks on crash. |
Fill factor drives B: probability a draw collides is the fill fraction f. At 5.2% (year 5) expected attempts = 1/(1 - 0.052) = 1.055. Five consecutive collisions: 0.052^5 = 3.8 ร 10^-7, then fall back to 8 chars (62^8 = 2.18 ร 10^14).
Pick: B (random + conditional put), with the store's key constraint as the only authority.
def create_code(long_url, owner):
for attempt in range(5):
code = "".join(secrets.choice(BASE62) for _ in range(7))
if links.put_if_absent(code, long_url, owner): # single-key conditional write
return code
code = "".join(secrets.choice(BASE62) for _ in range(8))
assert links.put_if_absent(code, long_url, owner)
return code
Custom aliases hit the same put_if_absent, so an alias that equals an existing generated code is simply a 409, and a generated code equal to an existing alias is just another retry. No separate namespace is needed. Reserved words are rejected before the write.
Cost: one extra round trip in about 5% of creates at year 5; no pre-checks (a read-then-write check races). Requires all creates to go to the single write region for strong uniqueness.
Change my mind if: fill passes ~30% (expected attempts 1.43; or move to 8 chars); we need active-active multi-region writes (then use option C with disjoint ranges per region so generated codes cannot collide, and route only custom aliases to a home region); or a security review wants proof of unpredictability (use secrets/CSPRNG, which the snippet does; with option C use a keyed Feistel permutation, not a plain multiplier, since a multiplicative scramble is recoverable from two samples).
6.2 The redirect hot path: caching, 301 vs 302, hot keys, takedown¶
Problem: 35k redirects/s peak, p99 < 50 ms, a viral link can take 100k/s on one key, yet disabling a malicious link must work within 60 s and clicks must be countable.
Choices
- 301 vs 302. 301 is cacheable by the browser and CDNs with no expiry, which saves load but loses analytics and makes takedown impossible for clients that cached it. 302 (or 307 to preserve method) with
max-age=0. Pick 302. If a customer explicitly wants SEO-style permanent redirects and no analytics, offer 301 per link. - Where to cache. Options: (a) Redis only; (b) Redis plus a small in-process LRU; (c) CDN caches the 302 for 60 s. (c) removes almost all origin load for viral links but the CDN serves clicks we never see (analytics must come from edge logs) and takedown latency equals the TTL. Pick (b), and keep (c) as a per-link opt-in for traffic spikes where edge logs are ingested.
- Hot keys. One Redis shard owns a key, so 100k/s on one key saturates it. The in-process LRU with a 5 to 10 s TTL means each of, say, 200 instances asks Redis for that key at most once per TTL, so Redis sees at most
instances / TTL= 200 / 10 s to 200 / 5 s = 20 to 40 reads/s for that key instead of 100k/s. - Stampede on miss. Single-flight per key in-process (one in-flight store read, others wait), plus jittered TTLs (24 h ยฑ 10%) so a cohort does not expire together.
- Negative caching. Scanners probe random codes; cache "not found" for 30 s so they do not hammer the store. Only cache a negative after an authoritative read (in a replica region, after a miss locally, check the write region once) so a just-created link is not 404'd by replication lag.
- Takedown path. Write
status=blockedto the store,DELthe Redis key, and publish an invalidation on a pub/sub channel that every instance subscribes to to evict the in-process entry. The in-process TTL (<= 10 s) bounds staleness if the message is lost, comfortably inside the 60 s target.
def redirect(code):
link = lru.get(code) or redis.get(code)
if link is None:
link = singleflight(code, lambda: store.get(code)) # authoritative read
if link is None:
redis.setex("nf:" + code, 30, 1); return 404
redis.setex(code, jitter(86400), link)
if link.status != "active": return 451 if link.status == "blocked" else 410
if link.expires_at and now() >= link.expires_at: return 410
emit_click_async(code, request) # bounded buffer, drop on overflow
return 302, link.long_url
Cache TTL must never exceed the link's remaining life: ttl = min(86400, expires_at - now).
Cost: two cache layers to invalidate; up to 10 s of staleness after disable if pub/sub drops; extra memory per instance (small: a few hundred thousand entries).
Change my mind if: sustained million-QPS campaigns appear (go CDN-first with edge-log analytics), or Redis tail latency dominates p99 (grow the in-process tier).
6.3 Counting a billion clicks per day¶
Problem: 12k events/s average, 35k peak, and one viral link can be 100k/s. A UPDATE links SET clicks = clicks + 1 per redirect creates a hot row and puts a write on the critical path.
Options
| Option | Behavior | Verdict |
|---|---|---|
| A. Increment a counter in the DB per click | Strongly consistent, hot row, redirect latency now includes a write, 35k writes/s | Reject |
B. INCR in Redis per click, flush periodically |
Cheap, but the counter is lost if the node dies before flush; no dimensions; one hot key again | OK for rate limiting, not analytics |
| C. Click event to a log, stream-aggregate to counts and OLAP | Redirect path unaffected; replayable; supports breakdowns; counts lag by seconds | Pick |
Design (C): the redirect instance buffers events locally and batches to Kafka (clicks, partitioned by code so one aggregator task sees all of a code's events, though a viral code then lands on one partition: 100k/s ร 100 B = 10 MB/s, still fine for a single partition). The aggregator keeps 1-minute tumbling windows per (code) in state and, on window close, upserts the absolute window total (replace, not add):
INSERT INTO click_counts (code, bucket_start, clicks)
VALUES ($1, $2, $3)
ON CONFLICT (code, bucket_start) DO UPDATE SET clicks = EXCLUDED.clicks;
Because the upsert replaces rather than increments, a replayed or re-emitted window (aggregator restart, at-least-once delivery from Kafka) writes the same value and is idempotent. Late events within an allowed lateness (say 5 minutes) cause the window to be re-emitted with a larger total. Total clicks for a link = sum over buckets, served from cached hour/day rollups.
Accuracy statement (say it aloud): counts are at-least-once at the log and exact at the aggregate if the log is complete, but the redirect drops events when its buffer overflows or Kafka is unreachable. The target is under 0.1% loss in normal operation, tracked by comparing redirect-side emitted vs dropped counters and aggregator-side consumed. If billing depends on clicks, we would add a local disk-backed spool on redirect nodes and dedupe on event_id.
Cost: an extra pipeline (Kafka, stream job, OLAP) to operate; counts are 10 s to 2 min stale; a viral key concentrates on one Kafka partition (mitigate by keying on code + random(0..7) and summing in the aggregator when a key is detected as hot).
Change my mind if: analytics is only a total counter with no breakdowns and exactness does not matter (Redis INCR with periodic persistence is far cheaper); or customers need sub-second dashboards (add a Redis sliding window for the last hour).
7. Failure Modes and Scaling¶
| Failure | Impact | Mitigation |
|---|---|---|
| Redis cluster down | All reads hit the store: 35k/s vs 3.5k/s normal (10x) | In-process LRU absorbs hot keys; store capacity provisioned for ~3x of normal miss traffic; load-shed anonymous creates first; request coalescing; circuit breaker so slow Redis does not add latency (fall through after 5 ms timeout). |
| Store partition/node loss | Keys on that partition unreadable if all replicas lost | 3 replicas across AZs, quorum reads; cache serves most reads meanwhile; creates for that partition fail with 503 and retry. |
| Write region down | Creates unavailable | Redirects continue from replicas in other regions. Promote another region's store to writer (runbook, target RTO 15 min); replication lag defines possible lost creates, so acknowledge creates only after cross-AZ durability, accept async cross-region RPO of seconds. |
| Kafka down | No analytics | Redirect drops events beyond the buffer (counted and alerted); redirects unaffected. |
| Aggregator lag | Stale stats | Autoscale on consumer lag; stats show as_of. |
| Create succeeded, idempotency row write failed | A client retry creates a second code | Write the idempotency row first with state pending, then the link, then mark done (retries see pending and wait or return the code); orphan sweeper for pending > 5 min. |
| Reputation service down | Cannot screen | Fail open for authenticated owners with history, fail closed (or queue) for anonymous creates; the async deep scan catches up. |
| Malicious URL accepted | Reputation harm, domain blocklisted by browsers or email providers | Async scan (Safe Browsing-style list plus internal heuristics) within minutes; takedown path above; rate limits per IP and account; CAPTCHA on anonymous creates; Link interstitial for unknown newly created domains; trust tiers. |
| Bad deploy of redirect service | Global outage | Canary 1% then 10%, auto-rollback on SLO burn. |
Hot keys. Read skew: in-process LRU plus Redis replicas for reads (or client-side key splitting code#0..3 for a detected hot key). Write skew does not exist for links (random keys). Click events for a hot code: salted Kafka key as in 6.3.
Multi-region. Redirect and cache run in every region; the links table is replicated asynchronously to each. Creates go to a single write region (3.5k/s peak does not justify active-active, and it keeps uniqueness trivial). A redirect miss in a replica region does one authoritative read from the write region before returning 404 (covers replication lag for just-created links); the creator's own region is also write-through cached. Custom aliases and idempotency keys are only ever written in the write region. Takedown writes go there and fan out invalidations to all regions' pub/sub.
8. Observability, Rollout and Cost¶
SLIs and SLOs¶
| SLI | SLO |
|---|---|
| Redirect success rate (non-4xx-by-design responses) | 99.99% monthly |
| Redirect server-side latency | p99 < 50 ms, p50 < 5 ms |
| Create success rate / latency | 99.9% / p99 < 300 ms |
| Click pipeline freshness | 95% of events aggregated within 2 min |
Click loss (dropped / emitted) |
< 0.1% |
| Takedown propagation | 99% within 60 s |
Alerts¶
- Page: redirect 5xx fast-burn (2% of the error budget in 1 h) or p99 > 100 ms for 5 min; store error rate; write-region create failure.
- Ticket: Kafka consumer lag > 5 min; click drop rate > 0.1%; key-space fill > 30%; cache hit ratio < 80%.
Rollout and migration (replacing an existing shortener)¶
- Shadow: new redirect service reads from the new store, compare responses against the old on mirrored traffic, no user impact.
- Dual write creates to old and new; backfill old links into the new store in key order; verify with row counts and sampled hash comparison.
- Canary reads 1% โ 10% โ 50% โ 100% by code hash, keeping the old path as fallback on miss.
- Cutover creates; keep the old store read-only for a rollback window; then decommission.
Cost drivers¶
- Storage: ~300 TB replicated across ~150 nodes. Levers: expire unclicked links after a policy window (for example no clicks in 24 months), tier cold links to cheaper storage with a slower first redirect (cache fill), compress
long_url. - Cache RAM: ~60 GB hot set is cheap; the lever is hit ratio, not size.
- Click pipeline: 100 GB/day raw; cut by sampling dimensions, 30-day raw retention then rollups only.
9. Alternatives and Trade-offs¶
| Decision | Chosen | Alternative | Why chosen / when to flip |
|---|---|---|---|
| Code generation | Random 7 chars + conditional put | Counter ranges; hash of URL | Simplest and non-enumerable at 5% fill; flip for active-active writes or fill > 30% |
| Redirect status | 302 | 301 | Analytics and takedown; flip per link for permanent SEO redirects |
| Store | Partitioned KV | Sharded Postgres/MySQL | Point lookups, 300 TB, single-key uniqueness; flip if team operates sharded SQL well |
| Counting | Event log + stream aggregate | DB increment; Redis INCR | Redirect path stays write-free; flip to Redis INCR if only a total is needed |
| Cache | Redis + in-process LRU | CDN edge caching | Fast takedown and exact analytics; flip for campaign spikes |
| Multi-region | Single write region, global reads | Active-active | 3.5k/s creates; avoids cross-region uniqueness; flip when create latency from far regions matters |
| Abuse screening | Sync blocklist + async scan | Sync deep scan | Keeps create p99 low; accepts a short exposure window |
10. Staff-Level Follow-up Questions¶
1. Why not just hash the URL so identical URLs share a code?
Deduplication sounds like a storage saving, but links carry owner, expiry, analytics and status, so two owners shortening the same URL need distinct codes. Hash truncation also collides and then needs the same retry logic as random codes. Per-owner dedupe is possible through the idempotency key or an (owner_id, hash(long_url)) index if product wants it, but that is a product decision, not a storage optimization.
2. A link is created and clicked 50 ms later in another region. What happens? Replication to that region may not have landed. The redirect misses locally, does one authoritative read from the write region, and serves the result while populating the local cache. We never negative-cache a miss until the authoritative read confirms it. The cost is one cross-region round trip on the first click only, which is acceptable.
3. How do you take down a phishing link globally within 60 seconds?
Write status=blocked to the store, delete the Redis key, and publish an invalidation that evicts in-process copies in all regions. If the message is lost, the in-process TTL of at most 10 s plus the Redis delete bound it well inside 60 s. This is also why we use 302 and not 301, and why the CDN caching opt-in has a TTL no longer than the takedown budget.
4. How would you support 10x traffic (350k redirects/s peak)? Redirect instances and Redis scale horizontally and are stateless or sharded by key. The store sees only misses, so it stays small if the hit ratio holds; I would confirm that with the hot-set size, which grows with distinct active links, not with QPS. Click events grow to 350 MB/s into Kafka, still modest with more partitions. The first real constraint is likely hot-key concentration, solved with the in-process tier and salted partition keys, then CDN edge caching.
5. How do you prevent the service from enabling spam and phishing at scale? Layers: authenticated creation with per-account and per-IP token buckets, CAPTCHA on anonymous creates, blocklist check on create, async scanning of new destinations, an interstitial for newly seen domains, immutable destinations, and abuse reporting that feeds the takedown path. Metrics watch for spikes in distinct domains per account. No layer is perfect, so the takedown latency and the account-level ban matter as much as detection.
6. Can you guarantee a custom alias is unique under concurrent requests?
Yes, because uniqueness is a single-key conditional write in one partition owned by one leader or quorum, not a read-then-write. Two concurrent creates of the same alias race on the condition and exactly one succeeds; the loser gets 409. This depends on routing alias writes to a single write region; in an active-active design aliases would need a home region per alias or a consensus-backed store.
7. Counts are wrong by 2% versus the customer's own server logs. Why, and what do you do?
Likely causes: bot and prefetch traffic (we count or they do not), dropped events from a full buffer, 302 responses followed by users who abandon, and browsers prefetching. We would diff emitted, dropped and consumed to find our own loss first, then classify user agents. If the customer needs billing-grade counts, we add a durable spool and event-id dedupe and publish the definition of a "click" so both sides measure the same thing.
8. How would you reclaim space from 182 B links?
Expire by policy (for example no clicks for 24 months, after a notice) and move cold links to a cheaper tier. A background reaper finds expired links through a time-bucketed index on expires_at, not a table scan. Reuse reclaimed codes only after a quarantine period, since immediate reuse would send old clicks to a new destination.
9. What is the first thing you would measure after launch? The cache hit ratio and the distribution of requests across codes, because the entire sizing rests on a 90% hit ratio from a 28 GB hot set. If real traffic is flatter than 80/20, the store must absorb more reads and the tier sizing changes. The second is create retry rate, to verify the 5% fill assumption.
11. Common Mistakes¶
| Mistake | Better |
|---|---|
| Check-then-insert for uniqueness | Single conditional put; handle the conflict |
| Incrementing a DB counter on each redirect | Async event log and aggregation; redirect path stays read-only |
| Relying on DB TTL for expiry | Enforce expires_at on read; TTL only reclaims space |
| Sequential IDs exposed directly | Random or keyed-permuted codes if links may be private |
| Ignoring abuse until the end | Rate limits, reputation checks, takedown are core design |
| Forgetting that a hot link is a hot key | In-process tier, replicas or key splitting, and a hot-key dashboard |
Back to: Framework and Estimation ยท Next: News Feed