Before drawing a single box, you need to agree on what the system actually does. This step is about compression — taking an ambiguous problem statement and narrowing it to a concrete, agreed-upon scope. The problem statement is deliberately left ambiguous. Your job is to ask the right questions and confirm the answers back.
The hardest part of requirements gathering for most people is knowing what to ask. The instinct is to ask about features — "can users edit their URLs?" — but the more important questions are about constraints: how many users? how fast must it be? what happens if it's down? Features define what the system does; constraints define how hard building it actually is.
A practical approach is to split requirements into two buckets and fill each one deliberately:
Functional requirements (what the system does): Walk through the user journey. A user arrives, does something, gets something back. What are those actions? Which ones are in scope? Name what is explicitly out of scope — this is as important as naming what is in scope, because it sets the boundary that prevents scope creep mid-design.
Non-functional requirements (how well it does it): Scale, latency, availability, consistency. These are the constraints that make the problem hard. A URL shortener serving 100 users is trivially simple. One serving 100M users requires real design thinking. You cannot have a meaningful architectural discussion without agreeing on these numbers first.
The most important call in this step is analytics. It sounds like a minor feature — but it determines the redirect type (302 vs 301), which determines whether you need a streaming pipeline at all, which is one of the most complex parts of the system. Getting this scope decision wrong early cascades through every subsequent step. This is why you surface it explicitly rather than assuming.
| Requirement | Decision |
|---|---|
| Create short URL | Given a long URL, return a unique 7-character short code |
| Redirect short URL | HTTP 302 redirect to original long URL (not 301 — see API design) |
| Custom alias | In scope — user can optionally specify their own short code |
| Analytics | In scope — click tracking required. This single decision drives the entire analytics pipeline design and the 302 redirect choice. |
| User login | In scope — assume users have accounts. This enables per-user analytics and deduplication logic. |
| URL editing | Out of scope — once a short code is created, the long URL it points to is immutable. |
| Link expiry | Out of scope for this session — the reclamation service handles code reuse separately. |
| DAU | 100M daily active users — this is the anchor for all traffic estimates. |
| Write RPS | ~1,000 URL creations/sec — moderate write load, manageable without exotic solutions. |
| Read RPS | ~10,000 redirects/sec (10:1 read:write ratio) — the system is read-heavy. Cache design is critical. |
| Availability | High — a broken URL shortener is immediately visible. Every broken link is a user-facing failure. |
| Consistency | Eventual OK for reads — a redirect serving a slightly stale cache entry is acceptable. But write uniqueness must be guaranteed — two different long URLs cannot share the same short code. |
This ratio tells you where to spend your design energy. Writes (URL creation) happen 1,000 times per second — manageable. Reads (redirects) happen 10,000 times per second — that's where the system will strain. Every architectural decision that follows should be evaluated through the lens of "does this make the read path faster?" The two-level cache, the pre-generated code pool (which keeps write latency low so it doesn't interfere with reads), and the fire-and-forget analytics approach all serve this goal.
Personas help you define the use cases concretely — who uses the system, what they do, and what they care about. For some systems this step is critical (a social network has dozens of meaningful user types with very different access patterns). For a URL shortener, it's simpler.
- Has a long URL to share
- Calls POST /shorten
- Gets back a short code
- Optionally specifies custom alias
- Has a short URL (from link, QR, etc.)
- Calls GET /{shortCode}
- Gets 302 redirected to long URL
- Click is tracked for analytics
For a URL shortener, the creator/consumer split cleanly covers the full functional surface. In production there are other personas — advertisers tracking campaign performance, developers using the API, admins managing abuse — but none of them change the core architectural decisions for this session's scope. Adding more personas here would just generate requirements we've already put out of scope. The right call is to name the two that drive the design and move on. Don't over-engineer the personas step for a simple system.
Estimation isn't a math test — it's a reasoning signal. The numbers you produce here constrain every architectural decision that follows. Get this wrong and your design is built on fiction. The key is to work from what you know (DAU) to what you need (RPS, storage, cache size) in a chain of logical steps.
Writes RPS: 100M DAU — assume ~10% of users create a URL on any given day, spread across 86,400 seconds. That gives roughly 1,000 writes/sec. Not a precise number, but the right order of magnitude.
Reads RPS: The 10:1 read:write ratio gives us 10,000 redirects/sec. This is the number that shapes the cache design — 10K requests per second is not something you can serve from a single database.
Why 7 characters: Base62 uses [a-zA-Z0-9] — no special characters, URL-safe. 62⁶ = 56 billion combinations. At 1,000 writes/sec that's only ~1.8 years of unique codes before you run out. 62⁷ = 3.5 trillion combinations — at the same write rate, that's over 100 years of headroom. 7 is the minimum safe length for this scale.
Storage: Each URL record is ~100 bytes (shortCode + longURL + userId + timestamps). Over 10 years at 1,000 writes/sec = ~3.15 trillion records × 100 bytes = ~35TB. Manageable on a distributed database cluster — no exotic storage solutions needed.
Cache sizing: Not all URLs are accessed equally — a small fraction gets the vast majority of traffic (the 80/20 rule applies hard here). If 20% of URLs receive 80% of traffic, you only need to cache ~20M active URLs × 100 bytes = ~2GB in Redis. That's cheap and fits on a single Redis instance.
| Method + Path | Request | Response |
|---|---|---|
| POST /api/v1/shorten | Body: longUrl, optional customAlias | { shortUrl } — 201 |
| GET /api/v1/{shortCode} | — | HTTP 302 → Location: longUrl |
The API definition forces you to think from the consumer's perspective before drawing any internal boxes. Two endpoints cover the full functional scope from step 1A — one for creating a short URL, one for using it.
The GET endpoint is deliberately minimal. The client sends a short code and gets a redirect back. There's no response body to parse, no JSON to decode — just a status code and a Location header. This is intentional: the consumer of a short URL should get to their destination as fast as possible.
This is one of the most common traps in URL shortener design. The two redirect codes feel similar but have completely opposite implications for analytics:
- ✗ 301 Permanent Redirect — the browser interprets this as "this URL has moved permanently." It caches the redirect locally and never hits your server again for subsequent visits. From an analytics perspective, this is catastrophic — every click after the first is invisible to you. The only data you have is the first click per browser. If analytics is in scope, 301 kills it entirely.
- ✓ 302 Temporary Redirect — the browser interprets this as "this URL has moved temporarily." It does not cache the redirect, so every click goes through your server. Every request gives you the opportunity to emit a click event to your analytics pipeline. This is the right choice when analytics is in scope.
Memory trick: 301 = Permanent = browser caches = you lose data. 302 = Temporary = browser always asks = you see everything.
Use 302. Analytics is in scope — we confirmed this in step 1A. Every redirect must hit our servers so we can emit a click event to the Kafka streaming pipeline. Yes, this means ~10,000 requests per second touch our servers instead of being absorbed by browser caches. But that's exactly why we designed the two-level cache — L1 local cache on each web server handles viral URLs without even hitting Redis. The additional server load is acceptable and manageable at this scale.
The system has two fundamentally different traffic patterns that need to be designed separately. Reads (redirects) are high-volume, latency-sensitive, and should almost never touch the database. Writes (URL creation) are lower-volume, slightly higher latency is acceptable, but must guarantee uniqueness. Mixing these concerns would produce a worse design for both.
The goal of the read path is to return the long URL as fast as possible. At 10,000 requests per second, every unnecessary database hit is a problem. The two-level cache exists specifically to absorb this load.
- L1 Local Cache check first — each web server has an in-process cache holding the hottest URLs. If a URL is going viral — say, a link tweeted by a celebrity getting 50,000 clicks per minute — that URL gets promoted to L1 on every web server. Requests are served entirely in-process, zero network hops. This is the fast path for the case that matters most.
- Redis Cache check — the shared L2 cache holds the broader set of recently active URLs. Most reads (~80-90%) hit here. The key is the shortCode, the value is the longUrl. A Redis lookup is a single network round-trip, typically sub-millisecond.
- DB lookup on miss — only cold URLs, rarely accessed URLs that have aged out of cache, reach the database. The shortCode column is indexed, so the lookup is fast. On the way back, the URL is populated into Redis and L1 so the next request doesn't hit the DB.
- Invalid code check — before any cache or DB lookup, validate that the shortCode looks like a valid 7-character Base62 string. This protects against bots probing random codes and generating useless DB load.
- Fire-and-forget analytics emit to Kafka — after the redirect is determined, emit a click event to Kafka. This is asynchronous — the 302 response goes back to the client immediately, without waiting for the Kafka write to complete. A lost event is acceptable; a slow redirect is not.
The write path has a harder problem: generating a unique code at 1,000 requests per second across multiple web servers without coordination overhead. The pre-generated pool solves this cleanly — the web server's job on a write is just to claim a code that's already been created, not to generate one on the fly.
- Duplicate URL check — first, check if this user has already shortened this exact URL. If yes, return the existing short code. This keeps the system idempotent: submitting the same URL twice gives the same result, which is what users expect. (Note: different users submitting the same URL get different codes — more on this in 5D.)
- Claim a code from the Code Pool table — the pre-generated pool always has 5,000–10,000 codes ready. The web server does an atomic delete-and-return: grab the next available code, remove it from the pool. No generation logic happens here. No uniqueness check against the URLs table. The code is guaranteed unique because the nanny service guaranteed it when creating the pool.
- Insert into URLs table — write the shortCode, longUrl, userId, and timestamps. The UNIQUE constraint on shortCode is a safety net, not the primary uniqueness mechanism.
- Return shortUrl to client — combine the base domain with the short code and return. The client can start using the link immediately.
Code generation is the write path bottleneck. At 1,000 writes/sec, any synchronous generation + uniqueness-check against the DB adds latency and creates a distributed coordination problem across multiple web servers. The design below eliminates both issues.
The region prefix idea was tempting at first — if each web server generates codes with a region-specific prefix (US servers generate "us_xxxxx", EU servers generate "eu_xxxxx"), then codes are structurally unique with no coordination needed. But this approach was abandoned for two reasons: it consumes part of the 7-character code space on a prefix, reducing the combinatorial headroom from 62⁷ to 62⁵; and it doesn't handle custom aliases cleanly, since a user-specified alias has no region prefix by definition. Mixing the two approaches made the design inconsistent.
The pre-generated pool approach is cleaner. A background service generates codes ahead of time and stores them in a pool table. Web servers simply claim the next available code when a write comes in — no generation, no uniqueness check, no coordination. The entire code generation problem moves off the hot path.
The nanny service is a singleton background job. Its only responsibility is to keep the pool full. It does this by checking the pool size periodically and generating new codes whenever the pool drops below a threshold.
- Pool target: 10,000 codes — at 1,000 writes/sec, this is a 10-second buffer. Enough time for the nanny to detect a low pool and refill it even if it runs every few seconds.
- Refill threshold: 5,000 codes — when the pool drops below 5,000 (half capacity), the nanny starts generating. At 1,000 writes/sec, this gives ~5 seconds of runway even if the nanny takes a moment to respond. This buffer-within-a-buffer is deliberate — it means the system never comes close to running out of codes under normal conditions.
- Generation algorithm: generate a random 7-character string from the Base62 alphabet [a-zA-Z0-9]. At a pool size of 10,000 codes against a space of 3.5 trillion, the probability of a collision is so low it's effectively zero. But the unique constraint on the pool table catches it anyway.
- Unique constraint as safety net — the
short_codecolumn in the pool table has a DB-level unique constraint. If the nanny somehow generates a duplicate, the INSERT fails silently (ON CONFLICT DO NOTHING) and the nanny moves on. No crash, no retry storm. The safety net costs nothing and prevents a theoretical edge case from becoming a production incident.
Without reclamation, the system has a slow leak. At 1,000 writes/sec, codes are consumed permanently — shortened URLs that nobody ever clicks again still hold their codes. Over time, the usable code space shrinks. The reclamation service prevents this by returning dormant codes to the pool.
- The Data Warehouse detects inactivity — a batch job runs nightly, scanning
last_accessed_atacross all URLs. Any URL with zero clicks in the past 3 months is a candidate for reclamation. The Data Warehouse already has all this click data from the analytics pipeline — no additional data collection is needed. - Soft delete first, not hard delete — when a URL is flagged inactive, it gets
state = INACTIVE. The record is not deleted. This matters: if someone follows an old link, they get a clear "URL expired" page rather than a random redirect to someone else's content (which would happen if the code was immediately reassigned). It also preserves history for abuse investigations. - One more month of grace period — after going INACTIVE, the URL waits one additional month before reclamation. This handles the case of a URL that had a traffic lull — a campaign that paused, a blog post that went quiet — without permanently losing the code. Only URLs that have been inactive for 4 months total get their codes reclaimed.
- Code re-enters the pool — the reclamation service sets
state = DELETEDand re-inserts the short code back into thecode_pooltable. From that point, the code is available for new URL assignments. The system is self-sustaining indefinitely.
The current nanny service approach is a good starting point but has open questions: What if the nanny goes down? The pool drains and writes start failing. What if two nanny instances run? Race conditions on code insertion. A more robust design might use a distributed token service (like Twitter's Snowflake or a dedicated ID generation service) or range-based pre-allocation per server. See the dedicated Code Generator deep dive →
Every time someone follows a short link, we chose 302 over 301 specifically so that request hits our servers. That request is a click event — it tells us who clicked, when, from where, and on what device. The analytics pipeline is how we capture that event without slowing down the redirect.
The core design constraint is simple: the redirect must be fast regardless of what happens to the analytics write. If the analytics system is slow or temporarily down, the redirect still completes in milliseconds. This is why the analytics write is fire-and-forget — we emit an event to Kafka and return the 302 response immediately. We don't wait to confirm the event was received or stored.
{
"event_type": "url_click",
"short_code": "abc1234",
"long_url": "https://example.com/...",
"user_id": "usr_xyz", // null for anonymous
"timestamp": "2026-08-16T14:23:11Z",
"ip_address": "203.0.113.42", // for geo analytics
"user_agent": "Mozilla/5.0...", // device type
"referrer": "https://twitter.com"
}
Analytics queries have two very different shapes, and no single storage system serves both well. Someone looking at a dashboard wants to know "how many clicks in the last hour?" — they need low-latency access to recent raw events. Someone running a monthly report wants "total clicks by country over the past 30 days" — they need pre-aggregated historical data, because scanning 30 days of raw events at 10K clicks/sec is billions of records. Lambda architecture handles both by using two separate layers.
- Raw Kafka events stored as-is
- Last 24–48 hours of data
- Query: "clicks in last hour"
- Store: Redis or time-series DB
- High write throughput, short TTL
- Pre-aggregated daily records
- clicks_per_day, clicks_per_country
- Query: "clicks last 30 days"
- Store: Data Warehouse (BigQuery etc.)
- Batch jobs run nightly
The analytics API serves as the query router — it knows which layer to hit based on the time range of the query. Recent queries go to the speed layer, historical queries go to the batch layer. The user sees a single analytics dashboard; the two-layer complexity is hidden behind the API.
At 10,000 redirects/sec, a single day generates ~864 million click events. A "last 30 days" query that scans raw events would touch 26 billion records — even with good indexing, that's not a user-friendly query. The batch layer pre-aggregates these into daily summaries (clicks_per_url_per_day, clicks_per_country_per_day) so the 30-day query reads 30 rows per URL, not 26 billion events. Two layers, two order-of-magnitude differences in query complexity.
The redirect response must be fast. If the Kafka write fails — Kafka is momentarily overloaded, there's a network hiccup, whatever — that's acceptable. A lost click event is not a data integrity issue. Nobody's money is lost. The URL still works. In contrast, if we waited for the Kafka write to complete before returning the 302, a slow analytics system would cause a slow redirect — which is the opposite of what TinyURL exists to provide. Kafka handles durability and retry internally; the web server's job ends at emit.
The database can handle reads — it's indexed on shortCode, lookups are fast. But at 10,000 reads per second, hitting the database for every redirect would saturate it quickly. A URL shortener's read pattern is also highly skewed: a small number of URLs get the vast majority of traffic. This makes caching extremely effective — you only need to cache the hot URLs to absorb most of the load.
The reason for two cache levels comes from a specific problem: viral URLs. When a link goes viral — a post shared by someone with millions of followers, a product launch announcement, a news story — that single URL might receive thousands of requests per second for a short window. Even Redis, shared across all web servers, becomes a bottleneck at that scale. The L1 local cache on each web server handles this by absorbing the viral URL's traffic entirely in-process.
L1 is small, fast, and selective. It doesn't hold all URLs — just the ultra-hot ones that would otherwise flood Redis with repeated requests for the same key.
- What goes in L1 — threshold-based promotion: A URL gets promoted to L1 when its request rate crosses a threshold — say, more than 100 requests per minute on a given web server. This is measured in the web server itself. Below the threshold, URLs are served from Redis. Above it, they live in L1 until traffic drops back down.
- Why threshold-based rather than just caching everything: If you promoted every URL to L1, you'd have thousands of entries per server consuming significant memory. L1 is valuable precisely because it's small and hot — fill it with cold URLs and you've wasted memory that could be used for JVM heap. Only viral URLs earn their spot in L1.
- Size: Small — 1,000 entries per server is plenty. If the top 1,000 URLs are in L1, those URLs account for a disproportionate fraction of total traffic. Everything else hits Redis.
- Eviction: LRU. A URL whose traffic drops below threshold ages out of L1 naturally. The next request for that URL misses L1 and goes to Redis — which is the right fallback.
Redis is the workhorse of the read path. It's shared across all web servers, so a URL cached in Redis is available to any server that handles that URL's traffic. The key insight for sizing is that URL traffic is highly skewed — you don't need to cache 35TB of URLs, just the active fraction.
- Size calculation: Assume 20% of all URLs account for the vast majority of traffic — these are "active" URLs (clicked at least once in the past 30 days). If there are ~50M URLs total, 20% = 10M active URLs × 100 bytes each = ~1GB. With Redis key overhead and headroom, budget 2GB. That's a single Redis instance — no sharding needed at this scale.
- Eviction policy — LRU: When Redis reaches capacity, evict the least recently used URL. Cold URLs that nobody has clicked in weeks naturally age out. Hot URLs that are clicked frequently stay in cache. This is the right eviction policy for a system where recency of access correlates strongly with future access.
- TTL — 30 days: Set a hard TTL of 30 days on every cache entry regardless of access frequency. This ensures that if a long URL is ever updated (even though we said URL editing is out of scope — a future feature), the cache doesn't serve stale data indefinitely. It also aligns with the reclamation service's inactivity threshold.
- Cache population on DB hit: When a request misses both L1 and Redis and hits the database, populate both Redis and L1 on the way back. The next request for that URL will hit L1 immediately — no additional network hops for the follow-up requests that often cluster around a newly-active URL.
Here's a question that seems simple but has a non-obvious answer: what happens when two different users submit the same long URL? Do they get the same short code, or different ones?
The instinct is to say "return the same short code" — it saves storage, it's efficient, same URL means same destination anyway. But this instinct is wrong when analytics is in scope. Here's why: if User A and User B both shorten the same URL and get the same short code, then every click on that short code is attributed to both of them. Their analytics are merged. You cannot tell how many of those 500 clicks came from User A's Instagram post versus User B's newsletter. The analytics data becomes meaningless for per-user attribution.
- Check: does this user already have a code for this exact URL?
- Yes → return the existing code
- No duplicate storage created
- Makes the API idempotent — safe to retry
- Each user gets their own unique short code
- Both codes point to the same long URL
- Each user's clicks are tracked under their code
- Analytics attribution stays clean per-user
The same-user deduplication is a product expectation: if you shorten the same URL twice, you should get the same short link back. It would be confusing to get a different code. And since both clicks would be attributed to you anyway, there's no analytics reason to create a new code — just return the one you already have.
The different-user case is where the design decision is actually interesting. Sharing a code between users conflates their analytics — you'd see 502 total clicks but have no way to know 500 were from User A and 2 were from User B. Separate codes solve this completely. Each code maps to one owner; all clicks on that code are attributed to that owner.
The storage cost is real but small: Multiple records pointing to the same long URL means some redundant storage. At 100 bytes per record, even a URL shared by 1,000 users only costs 100KB of extra storage. At 35TB total over 10 years, this is noise.
The analytics benefit is significant: Per-user attribution is the whole point of the analytics system. If User A is a marketing manager running a campaign and User B is a developer testing a link, merging their click data makes both users' analytics useless. Separate codes preserve the integrity of the analytics data that the 302 decision was made to collect in the first place.
The data model flows directly from the access patterns established in the design. The primary access pattern on the read path is: given a short_code, return a long_url. This is a pure key-value lookup — no joins, no range scans, no aggregations. That access pattern points toward a simple, indexed table where short_code is the primary key.
The write path adds two pieces of context: user_id (for same-user deduplication and analytics attribution) and state (for the soft-delete reclamation lifecycle). The data model is intentionally simple — complexity lives in the application logic, not the schema.
| Column | Type | Notes |
|---|---|---|
| id | BIGINT | Auto-increment primary key |
| short_code | VARCHAR(7) | UNIQUE constraint — safety net for concurrency. Indexed. |
| long_url | TEXT | Original URL (up to 2048 chars) |
| user_id | VARCHAR | Owner — enables same-user deduplication check |
| state | ENUM | ACTIVE, INACTIVE, DELETED |
| created_at | TIMESTAMP | UTC. Immutable. |
| last_accessed_at | TIMESTAMP | Updated on every redirect. Used by DW to flag inactive URLs. |
| Column | Type | Notes |
|---|---|---|
| code | VARCHAR(7) | Primary key. Pre-generated Base62 code, not yet assigned. |
| created_at | TIMESTAMP | When the nanny generated this code |
Mixing pre-generated codes with assigned URLs would mean the URLs table contains both live records and "reserved" records that aren't real URLs yet. That complicates every query: reads would need to filter out reserved entries, analytics would need to exclude them. Keeping them separate means the URLs table is clean — every row is a real, assigned short URL. The pool table is ephemeral and small: at 10,000 entries × ~10 bytes per code, it's essentially nothing. The clean separation is worth it.
The primary access pattern (point lookup by short_code) maps naturally to either SQL or NoSQL. At this scale — ~35TB over 10 years, 10K reads/sec — a well-configured SQL database (PostgreSQL, MySQL) handles this comfortably. NoSQL (Cassandra, DynamoDB) becomes more compelling at significantly higher scale where horizontal sharding is needed, or if write throughput is dramatically higher. For TinyURL at the stated scale, SQL is the simpler and perfectly adequate choice. The UNIQUE constraint on short_code, the ability to do the duplicate-check query (SELECT by user_id + long_url), and the soft-delete state management all benefit from SQL semantics.
The session covered the URLs table and Code Pool table. The analytics event schema (what gets stored after Kafka consumers process click events), a User table (if auth is extended), and detailed index strategy (composite index on user_id + long_url for duplicate detection) were not reached. These will be added in a follow-up session.