Design TinyURL

A URL shortening service at scale. 100M DAU, 1,000 writes/sec, 10,000 reads/sec. This page documents the full design session — every decision, every tradeoff, every deep dive — exactly as it was reasoned through.

Read-Heavy 10:1 Caching Streaming Code Generation Lambda Architecture ~45 min session
1A Requirements 1B Personas 2 Estimations 3 API Design 4 High-Level Design 5A Code Generation 5B Analytics Pipeline 5C Cache Design 5D Duplicate URLs 6 Data Model
1A Requirements Clarification ⏱ ~5 min

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.

RequirementDecision
Create short URLGiven a long URL, return a unique 7-character short code
Redirect short URLHTTP 302 redirect to original long URL (not 301 — see API design)
Custom aliasIn scope — user can optionally specify their own short code
AnalyticsIn scope — click tracking required. This single decision drives the entire analytics pipeline design and the 302 redirect choice.
User loginIn scope — assume users have accounts. This enables per-user analytics and deduplication logic.
URL editingOut of scope — once a short code is created, the long URL it points to is immutable.
Link expiryOut of scope for this session — the reclamation service handles code reuse separately.
DAU100M 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.
AvailabilityHigh — a broken URL shortener is immediately visible. Every broken link is a user-facing failure.
ConsistencyEventual 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.
Why the 10:1 read:write ratio matters

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.

1B Personas & Use Cases ⏱ ~2 min

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.

Persona 1 — Creator
  • Has a long URL to share
  • Calls POST /shorten
  • Gets back a short code
  • Optionally specifies custom alias
Persona 2 — Consumer
  • Has a short URL (from link, QR, etc.)
  • Calls GET /{shortCode}
  • Gets 302 redirected to long URL
  • Click is tracked for analytics
Why two personas is enough for this system

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.

02 Capacity Estimation ⏱ ~5 min
Write RPS
~1,000
100M DAU ÷ 86,400s (assumed ~10% daily active)
Read RPS
~10,000
10:1 read:write ratio
Code space
3.5T
62⁷ = 3,521B combinations — 10 years of headroom
Storage / 10 yr
~35 TB
3.15T codes × ~100 bytes/record
Redis cache
1–2 GB
~20% active URLs cached; 20M × ~100B = ~2GB max
Code pool
10,000
Pre-generated codes always in DB; refill below 5K

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.

Walking through the key calculations

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.

03 API Design ⏱ ~5 min
Method + PathRequestResponse
POST /api/v1/shortenBody: 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.

⚠ Very Common Mistake — 301 vs 302

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.

🎯
Decision — 302

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.

04 High-Level Design ⏱ ~10 min
System architecture — write path + read path
Client Web / Mobile Load Balancer Web Server L1 Local Cache stateless · auto-scale Redis Cache L2 shared Primary DB URLs table Code Pool pre-generated Nanny Service refills pool Kafka Stream click events Data Warehouse analytics + reclaim Reclaim Service soft-delete → pool read write claim fire & forget Read path Write path Code claim Async / background L1 = local web server cache (viral URL fast path) · L2 = shared Redis · DB = primary URL store

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.

Read Path — GET /{shortCode}

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.
Write Path — POST /shorten

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.
5A Deep Dive — Code Generation
Why this deserves a deep dive

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.

Two approaches considered — and why approach 2 was chosen
Approach A — Region Prefix • Each web server generates codes independently • Region prefix encodes the server identity • e.g. US-1: "us1xxxx", US-2: "us2xxxx" ✗ Reduces 7-char code space with prefix ✗ Still needs coordination for custom aliases ✗ Mixed approach causes confusion ✗ Abandoned — mixed with pool approach Approach B — Pre-generated Pool ✓ • Nanny service pre-generates codes offline • Codes stored in pool table (10K buffer) • Web servers claim next available code ✓ No generation on the hot write path ✓ No distributed coordination needed ✓ Unique constraint as concurrency safety net ✓ Nanny refills async, never blocks requests

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.

Nanny Service Design

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_code column 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.
Code Reclamation Service

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_at across 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 = DELETED and re-inserts the short code back into the code_pool table. From that point, the code is available for new URL assignments. The system is self-sustaining indefinitely.
Open question — needs further research

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 →

📖 Full Code Generator Deep Dive →
5B Deep Dive — Analytics Pipeline

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.

Analytics pipeline — from click event to dashboard query
302 Response click detected Kafka Topic fire-and-forget Stream Processor Flink / Spark Real-time Store raw events · last 24h Data Warehouse aggregated · historical Analytics API serves dashboard ↑ recent queries → from real-time ↑ historical queries → from DW
Kafka Event Payload
{
  "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"
}
Lambda Architecture — why you need two serving layers

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.

Speed Layer (Real-time)
  • 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
Batch Layer (Historical)
  • 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.

🎯
Why this matters — the math

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.

Why fire-and-forget is the right pattern here

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.

5C Deep Dive — Cache Design

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.

Two-level cache hierarchy
Request GET /abc1234 L1 — Local Cache in-process · per server viral URLs only threshold-based promotion HIT → return immediately ✓ miss L2 — Redis shared · all servers ~1–2 GB · LRU eviction 20% of active URLs HIT → return + update L1 ✓ miss Primary DB indexed on short_code ~35 TB total always has the answer HIT → populate L2 + L1 ✓
L1 — Local In-Process Cache

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.
L2 — Shared Redis Cache

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.
5D Deep Dive — Duplicate URL Handling

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.

Same URL submitted by two different users
User A submits URL X User B submits URL X Same URL? different users same URL X Code A → URL X User A's analytics ✓ Code B → URL X User B's analytics ✓ Two codes preserve per-user click analytics Deliberate design choice
Same user, same URL → same code
  • 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
Different users, same URL → new code
  • 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 tradeoff — analytics wins over storage

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.

06 Data Model

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.

URLs Table — primary store
ColumnTypeNotes
idBIGINTAuto-increment primary key
short_codeVARCHAR(7)UNIQUE constraint — safety net for concurrency. Indexed.
long_urlTEXTOriginal URL (up to 2048 chars)
user_idVARCHAROwner — enables same-user deduplication check
stateENUMACTIVE, INACTIVE, DELETED
created_atTIMESTAMPUTC. Immutable.
last_accessed_atTIMESTAMPUpdated on every redirect. Used by DW to flag inactive URLs.
Code Pool Table — pre-generated codes
ColumnTypeNotes
codeVARCHAR(7)Primary key. Pre-generated Base62 code, not yet assigned.
created_atTIMESTAMPWhen the nanny generated this code
Why the code pool is a separate table — not part of URLs

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.

Storage choice — SQL vs NoSQL

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.

Session ended here — data model not fully completed

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.