System Design: URL Shortener
A capstone system design walkthrough — designing a URL shortening service end to end — covering the core domain model, short-code generation strategies and their trade-offs, the read-heavy caching architecture that makes redirects fast at scale, custom aliases and collision handling, expiration and cleanup, analytics on click events, and the specific read/write asymmetry and abuse-prevention demands that make a "simple" URL shortener a genuinely instructive system design problem.
Table of Contents
- Introduction
- Why a URL Shortener Is a Different Kind of Hard
- The Core Domain Model
- The Mapping Store: The Source of Truth for Short Code → Long URL
- Short Code Generation Strategies
- Idempotency and Duplicate Submission
- The Redirect Path: Optimizing the Hottest Read in the System
- Custom Aliases and Collision Handling
- Expiration, Deactivation, and Cleanup
- Click Analytics as an Asynchronous, Decoupled Concern
- Abuse Prevention and Malicious URL Handling
- Data Security and Compliance
- Consistency, Availability, and the CAP Trade-off for a Shortener
- Scaling the System
- Observability for a URL Shortener
- Common Pitfalls
- Quick Reference Table
- Conclusion
Introduction
A URL shortener takes the general system design vocabulary covered in this series' System Design guide — key generation, caching, read/write scaling, rate limiting — and applies it to a problem that's deceptively simple on the surface (map a short string to a long one) but is one of the best teaching examples in this series precisely because nearly every interesting decision is a genuine trade-off with no single right answer: how codes are generated, how aggressively reads are cached, and how abuse is prevented without punishing legitimate users. This guide walks through designing such a system end to end, drawing directly on this series' Caching, Database Sharding, Rate Limiting, and Data Pipeline guides, each of which turns out to be a direct, load-bearing application here rather than incidental background.
Client → Create Short URL API → [generate/validate code] → Mapping Store (source of truth)
↓
Cache (hot path for redirects)
↓
Client → GET /{code} → Cache lookup → 301/302 redirect → (async) Click Event → Analytics Pipeline
1. Why a URL Shortener Is a Different Kind of Hard
The read/write ratio is extreme, and the design should be built around that from the start
Most systems covered in this series have a read/write ratio that's high but not extreme. A URL shortener's ratio is dramatically skewed — a single short URL, once created, might be redirected millions of times (a link shared in a viral post, an ad campaign, a QR code printed on packaging) while being written exactly once. This is why the redirect path (Section 6) gets disproportionate design attention in this guide relative to the creation path — optimizing the write path at the expense of the read path would be optimizing the wrong 0.001% of the system's actual traffic.
The redirect must be fast enough that the shortener is never the noticeable bottleneck
A user clicking a shortened link has zero tolerance for the shortener adding
perceptible latency before the redirect happens — this is a pure infrastructure
layer, and its entire value proposition disappears if it's slow.
Unlike a system where users understand they're waiting for meaningful work to happen, a redirect has no inherent value the user is willing to wait for — every millisecond of added latency here is pure overhead with no offsetting benefit, which is precisely why aggressive caching (Section 6) is this guide's central architectural decision rather than an optional optimization layered on later.
Short codes are a scarce, shared namespace that must be managed carefully
A critical, freeing realization for the design that follows: a URL shortener, in the overwhelming majority of real-world designs, does not need a globally coordinated, strictly sequential ID generator to hand out short codes safely — it needs a code generation strategy (Section 4) that avoids collisions with acceptably low probability, or resolves them cheaply when they do occur, without every code-generation request contending on a single shared counter. This mirrors the "don't over-coordinate what doesn't need coordination" discipline covered in this series' Distributed ID Generation guide, applied here to the shortener's core namespace.
2. The Core Domain Model
Modeled simply, deliberately — this domain doesn't need a heavy DDD treatment
public record ShortCode(string Value); // e.g. "aZ3xQ9"
public record UrlMapping(
ShortCode Code,
string LongUrl,
UserId? Owner, // null for anonymous/unauthenticated creation, if supported
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
bool IsActive);
Per this series' DDD guide's own guidance that not every domain warrants a rich aggregate model, a URL mapping is a simple value with a small, well-understood lifecycle (Section 8) — modeling it as a straightforward record with explicit fields, rather than a heavyweight aggregate with elaborate behavior, is the right level of ceremony for what is fundamentally a lookup-table problem with a few genuinely interesting edges (Sections 4, 7, 8, 10) around that simple core.
Separating the mapping's identity (the code) from its metadata
public class UrlShortenerService
{
public async Task<ShortCode> CreateAsync(string longUrl, UserId? owner, TimeSpan? ttl)
{
ValidateUrl(longUrl); // per Section 10 — reject malformed or known-malicious URLs early
var code = await _codeGenerator.GenerateAsync(); // per Section 4
var mapping = new UrlMapping(code, longUrl, owner, DateTimeOffset.UtcNow, ttl.HasValue ? DateTimeOffset.UtcNow + ttl : null, true);
await _store.SaveAsync(mapping); // per Section 3
return code;
}
}
Keeping code generation (Section 4), validation (Section 10), and persistence (Section 3) as distinct, composable steps — rather than one large method conflating all three — matches this series' Separation of Concerns discussion and makes each piece independently testable and swappable (a different code generation strategy shouldn't require touching validation or persistence logic).
3. The Mapping Store: The Source of Truth for Short Code → Long URL
A key-value access pattern, which should drive the storage choice directly
The dominant access pattern is a single, simple lookup: given a short code,
return the long URL — no joins, no complex queries, no relational structure
genuinely needed for the core mapping itself.
As covered in this series' NoSQL/Key-Value Store guide, this access pattern is close to a textbook fit for a key-value store (DynamoDB, Cassandra, or similar) rather than a relational database — the mapping store doesn't need relational features for its core job, and a key-value store's horizontal scaling characteristics (Section 13) line up naturally with the read volume Section 1 describes.
Schema, kept intentionally minimal
-- Even if implemented on a relational engine for operational familiarity,
-- the schema itself stays deliberately simple, per the access pattern above
CREATE TABLE url_mappings (
short_code VARCHAR(10) PRIMARY KEY,
long_url TEXT NOT NULL,
owner_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NULL,
is_active BOOLEAN NOT NULL DEFAULT true
);
Per this series' Database Schema Design guide's minimalism principle, resisting the urge to add speculative columns or normalize this table further than the actual access pattern warrants keeps both the write path (Section 5) and the read path (Section 6) simple — additional structure (click counts, tags, folders) belongs in separate tables or services (Section 9) that don't need to sit on the hot redirect path.
Why the mapping store itself is not where redirect-time reads should land
Per Section 1's read/write ratio: the mapping store, however well-indexed,
should almost NEVER be hit directly by a redirect request in steady state —
it's the source of truth for MISSES on the caching layer (Section 6), not
the primary read path itself.
This distinction matters enough to state explicitly here, ahead of Section 6's detail: the mapping store's job is durability and correctness, not redirect-time latency — conflating "the source of truth" with "the thing that serves the hot read path" is exactly the design mistake this guide's emphasis on caching (Section 6) exists to prevent.
4. Short Code Generation Strategies
Random generation with a collision check — simple, and good enough at reasonable scale
public async Task<ShortCode> GenerateAsync()
{
for (int attempt = 0; attempt < MaxAttempts; attempt++)
{
var candidate = RandomBase62String(length: 7); // ~3.5 trillion possible codes at length 7
if (!await _store.ExistsAsync(candidate)) return new ShortCode(candidate);
}
throw new CodeGenerationExhaustedException(); // vanishingly rare at reasonable namespace fill rates
}
Per this series' Distributed ID Generation guide's comparison of strategies, generating a random Base62 string and checking for a collision before committing is simple to reason about and, at a namespace size that stays well below saturation, has a genuinely low collision probability — the "generate, check, retry on the rare collision" loop above is a perfectly reasonable default, not a naive shortcut, for the traffic volumes most systems in this space actually see.
Counter-based generation with encoding, for guaranteed uniqueness without a collision check
// A distributed, monotonically-increasing counter (per this series' Snowflake ID discussion),
// encoded to Base62 — GUARANTEES no collision, at the cost of some coordination on the counter itself
var id = await _distributedCounter.NextAsync();
var code = Base62Encode(id);
As covered in this series' Distributed ID Generation guide's Snowflake-style ID discussion, encoding a guaranteed-unique, monotonically increasing ID (generated via a coordinated counter, or a Snowflake-style scheme combining a timestamp, a worker ID, and a local sequence to avoid a single shared bottleneck) into Base62 removes collision handling entirely, trading the small complexity of maintaining that counter for the simplicity of never needing a retry loop — worth the trade at high enough creation volume that even rare collisions would add up to meaningful retry overhead.
Choosing between the two: a genuine trade-off, not a settled question
Random + collision check: simpler to implement, no shared counter to coordinate,
slightly variable latency on the rare collision retry.
Counter-based: guaranteed uniqueness, no retry loop, but requires SOME form of
coordination (even if distributed/sharded, per Snowflake-style schemes) to
avoid the counter itself becoming a bottleneck or a single point of failure.
Per this series' System Design guide's general encouragement to state trade-offs explicitly rather than presenting one option as objectively correct, this is a genuine either/or: most real systems at moderate scale reach comfortably for random-with-retry for its simplicity, and reach for a coordinated counter scheme specifically once creation volume or collision-retry overhead genuinely justifies the added coordination complexity.
5. Idempotency and Duplicate Submission
Why the same long URL being shortened twice isn't automatically a bug to prevent
Unlike this series' Payment Processing and Order Management guides, where a
duplicate submission is a serious correctness bug (double-charging, double-
shipping), two different requests shortening the SAME long URL producing TWO
different short codes is often perfectly fine — each represents a distinct
"share instance" a user might want tracked separately (Section 9).
Worth being explicit about this contrast with this series' other capstone guides: idempotency here is not about preventing "duplicate effect" in the financial or inventory sense — it's specifically about preventing a client's own retry (a network timeout on the create call) from producing two codes for what the client considers one logical creation request, which is a narrower, more classic idempotency-key use case.
Idempotency keys for the create-request retry case specifically
[HttpPost("/shorten")]
public async Task<IActionResult> CreateShortUrl(
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CreateShortUrlRequest request)
{
if (idempotencyKey is not null)
{
var existing = await _idempotencyStore.GetResultAsync(idempotencyKey);
if (existing is not null) return Ok(existing); // the SAME code as the original request
}
var code = await _shortenerService.CreateAsync(request.LongUrl, request.Owner, request.Ttl);
if (idempotencyKey is not null) await _idempotencyStore.SaveResultAsync(idempotencyKey, code);
return Ok(code);
}
This is the same mechanism introduced generally in this series' Redis guide's rate-limiting section and applied identically in this series' Payment Processing guide, just scoped to a narrower purpose here — a client that retries a timed-out create request with the same idempotency key gets back the original code rather than a second, orphaned one, without the system needing to treat "same long URL, different key" as anything other than two legitimate, independent shortenings.
6. The Redirect Path: Optimizing the Hottest Read in the System
Caching as the primary architectural decision, not an afterthought
GET /{code} → Cache lookup (Redis/Memcached, per this series' Caching guide) →
HIT: redirect immediately, no database touched at all →
MISS: read from the mapping store (Section 3), populate the cache, THEN redirect
Per this series' Caching guide's cache-aside pattern, a redirect request should hit the cache first in the overwhelming majority of cases, given Section 1's read/write ratio — a well-tuned cache should absorb nearly all redirect traffic, leaving the mapping store to handle only genuine cache misses (newly created or rarely-accessed codes) and the write path itself.
Cache eviction policy suited to this specific access pattern
Real-world link popularity follows a heavily skewed, long-tail distribution
(per this series' Caching guide's LFU/LRU discussion) — a small fraction of
codes account for most redirects. An LFU (least-frequently-used) or a hybrid
policy that favors genuinely popular codes over merely recently-created ones
fits this access pattern better than a naive LRU alone in many cases.
As covered in this series' Caching guide's eviction policy comparison, the choice between LRU and LFU (or a hybrid) is worth making deliberately here rather than defaulting blindly — a viral link's popularity can spike suddenly and needs to enter and stay in cache, while a large number of one-off, rarely-clicked links shouldn't crowd out cache space that would better serve the genuinely hot fraction.
301 vs. 302 redirects — a real trade-off with a non-obvious answer
301 (permanent redirect): browsers and CDNs may cache the redirect target
THEMSELVES, meaning subsequent clicks from the SAME client never even hit
the shortener again — great for latency and infrastructure cost, but means
the shortener LOSES VISIBILITY into repeat clicks from that client (Section 9).
302 (temporary redirect): every click hits the shortener, giving full click
visibility, at the cost of the shortener remaining in the loop for every click.
Per this series' HTTP Caching guide's discussion of redirect semantics, this is a genuine product trade-off, not a technical detail with one correct answer — services prioritizing infrastructure cost and raw redirect speed lean 301; services whose business model depends on complete click analytics (Section 9) deliberately choose 302 despite the extra load it keeps on the shortener, and the right choice depends entirely on which of those the product actually needs.
7. Custom Aliases and Collision Handling
Custom aliases invert the generation problem: the user picks the key, not the system
public async Task<Result<ShortCode>> CreateCustomAsync(string requestedAlias, string longUrl)
{
if (await _store.ExistsAsync(requestedAlias))
return Result.Failure<ShortCode>("Alias already taken"); // no silent overwrite, no retry-with-different-code
return Result.Success(new ShortCode(requestedAlias));
}
Unlike Section 4's system-generated codes, a custom alias is a direct claim on the namespace by the user — collision here has a different correct resolution than Section 4's "just generate a different one automatically," since silently substituting a different code the user didn't ask for would be a genuinely confusing product experience; the honest response is telling the user the alias is taken and letting them choose another.
Reserved word and format validation for custom aliases specifically
Custom aliases need their own validation layer (per this series' Input
Validation guide) — reserved paths the shortener's own API uses ("api",
"admin", "shorten"), a character set restricted to what's safe in a URL
path without additional encoding, and a length ceiling distinct from the
fixed length Section 4's generator produces by design.
Because a custom alias is user-supplied free text rather than system-generated, it needs its own validation pass distinct from Section 4's generated codes — rejecting reserved paths, enforcing an allowed character set, and bounding length, all before the collision check above even runs.
8. Expiration, Deactivation, and Cleanup
TTL as a first-class, optional property of a mapping, not a bolted-on feature
Per Section 2's domain model: expires_at is nullable — a mapping with no
expiration lives indefinitely (the common case for most shorteners), while
one created with a TTL (a time-limited promotional link, say) becomes
inactive automatically once past its expiration.
Treating expiration as an optional, per-mapping property from the start — rather than assuming all links live forever, or retrofitting expiration later — avoids the kind of disruptive schema change this series' guides on evolving data models generally warn against; both permanent and time-limited links are first-class cases the redirect path (Section 6) needs to check regardless.
Checking expiration at redirect time, and the cache-invalidation wrinkle it creates
public async Task<RedirectResult> ResolveAsync(ShortCode code)
{
var cached = await _cache.GetAsync(code);
if (cached is not null)
{
if (cached.ExpiresAt is { } exp && exp < DateTimeOffset.UtcNow)
return RedirectResult.Expired; // a cached entry can itself have gone stale-expired since caching
return RedirectResult.Found(cached.LongUrl);
}
// cache miss path per Section 6...
}
A subtlety worth calling out explicitly: because Section 6's cache can hold an entry longer than that entry's own TTL if cache eviction doesn't happen to coincide with expiration, the redirect path needs its own expiration check against the cached value's expires_at, rather than trusting that an expired mapping will have already been evicted from cache by the time it's requested — cheap to check, and closes a real correctness gap.
Cleanup as a background process, not a redirect-time side effect
A scheduled background job (per this series' Background Services guide) sweeps
expired mappings periodically, marking them inactive or removing them from
the mapping store — the redirect path (Section 6) should never be responsible
for triggering cleanup as a side effect of serving a single request.
Per this series' Background Services guide's separation of concerns, keeping cleanup as its own scheduled process — rather than something a redirect request triggers inline — keeps the hot redirect path (Section 6) free of extra work that has nothing to do with serving that specific request quickly.
9. Click Analytics as an Asynchronous, Decoupled Concern
Why analytics must never sit on the synchronous redirect path
A user clicking a shortened link should NEVER wait on a write to an analytics
store before receiving their redirect — per Section 1's latency stakes, any
synchronous dependency here directly undermines the system's core value proposition.
As covered in this series' Event-Driven Architecture guide's fire-and-forget event publishing pattern, recording a click event should be an asynchronous, best-effort publish to a queue — the redirect response goes out immediately, and the click event is processed by a separate analytics pipeline entirely decoupled from the request that generated it.
The click event as its own lightweight, append-only record
public record ClickEvent(ShortCode Code, DateTimeOffset ClickedAt, string? Referrer, string? UserAgentHash, string? CountryCode);
_eventPublisher.PublishFireAndForget(new ClickEvent(code, DateTimeOffset.UtcNow, referrer, userAgentHash, country));
Per this series' Event-Driven Architecture guide, publishing a lightweight click event to a stream (Kafka or similar) — rather than writing directly to an analytics database from the redirect handler — lets the analytics pipeline (aggregation, dashboards, per-link click counts) scale and evolve completely independently of the redirect path's own scaling needs (Section 13).
Accepting some data loss here as a deliberate, informed trade-off
Given Section 1's latency stakes: a fire-and-forget publish trades a small,
bounded risk of losing an occasional click event (during a publisher
failure) for guaranteeing the redirect itself is never slowed down or
blocked by analytics infrastructure — usually the correct trade for this domain.
Unlike this series' Payment Processing and Order Management guides, where losing an event would mean losing money or an order, losing an occasional click event here is a low-stakes, acceptable trade-off for keeping the redirect path's latency guarantees genuinely unconditional — worth stating explicitly as a deliberate choice rather than an oversight, since it would be the wrong choice in a domain with different stakes.
10. Abuse Prevention and Malicious URL Handling
Why an open "shorten any URL" endpoint is a genuine abuse vector
An anonymous, unauthenticated shortening endpoint is a well-known vector for
phishing (hiding a malicious destination behind a trustworthy-looking short
domain) and for spam (mass-generating short links for unsolicited content) —
this isn't a hypothetical concern specific to this guide's caution; it's a
documented, recurring abuse pattern across real URL shortening services.
As covered in this series' OWASP Top 10 and Abuse Prevention guides, a URL shortener's openness is precisely what makes it valuable and precisely what makes it attractive to abuse — designing for this from the start (rather than reactively after abuse is discovered) is standard practice for any service that will accept URLs from the public internet.
Rate limiting creation, distinct from rate limiting redirects
if (!await _rateLimiter.TryAcquireAsync($"create:{clientIdentifier}"))
{
return StatusCode(429, "Rate limit exceeded on link creation");
}
Per this series' Rate Limiting guide's per-endpoint policy discussion, the create endpoint needs its own, generally much stricter rate limit than the redirect endpoint — redirects are the system's core value delivered to end users clicking a link they didn't create, while creation is where mass-abuse (spam link generation) actually happens, so the two endpoints warrant genuinely different limiting policies rather than one blanket rule.
Checking destination URLs against known-malicious lists before accepting them
var reputationResult = await _urlReputationService.CheckAsync(request.LongUrl); // e.g. Google Safe Browsing API
if (reputationResult.IsMalicious)
{
return BadRequest("This URL has been flagged as unsafe and cannot be shortened.");
}
Per this series' Third-Party API Integration guide's "use a specialist service, don't reimplement" principle (echoed throughout this series, most directly in the Payment Processing guide's approach to card data), checking submitted URLs against an established URL reputation service before accepting them is far more reliable than any bespoke detection logic this system could reasonably build itself — and re-checking periodically after creation matters too, since a benign destination can turn malicious after the short link is already in circulation.
11. Data Security and Compliance
The long URL itself may contain sensitive query parameters
A shortened URL's destination can embed tokens, session identifiers, or other
sensitive query-string data the ORIGINAL creator put there — the shortener
should treat the stored long URL with the same access-control discipline
this series' Secret Management guide applies to any sensitive string, even
though the shortener itself didn't choose to embed that sensitivity.
Per this series' Data Privacy guide's general data-minimization principle, access to the mapping store's raw long URLs should be scoped narrowly (an operator debugging a specific redirect issue doesn't need broad read access to every stored mapping), and any analytics or logging (Section 14) that touches long URLs should be mindful that they can carry sensitive data the shortener never asked for and shouldn't casually persist or expose further than necessary.
Click analytics and user privacy
Per Section 9's click event schema deliberately storing a HASHED user agent
and a coarse country code rather than a raw IP address — per this series'
Data Privacy guide's data-minimization principle, analytics should collect
the coarsest data that still serves the actual product need, not the
richest data technically available.
As covered in this series' Data Privacy guide, click analytics is a place where it's tempting to log everything technically available (full IP, precise geolocation, complete user agent string) — deliberately minimizing to what a legitimate analytics use case actually needs (rough geography, referrer, timestamp) is both a genuine privacy practice and, in many jurisdictions, a real compliance consideration for anything that could constitute personal data.
Audit logging for account-owned links, distinct from click analytics
logger.LogInformation("ShortUrl {Code} created by {UserId} for destination {LongUrlHash}", code, userId, longUrlHash);
For authenticated creation (an account-owning user creating and managing links), standard structured logging per this series' Structured Logging guide applies — logging the fact and actor of creation/deactivation, while being deliberate (per the note above) about whether the actual long URL belongs in a plaintext log versus a hashed or redacted form, depending on how sensitive a given deployment's typical destinations tend to be.
12. Consistency, Availability, and the CAP Trade-off for a Shortener
Why this system leans toward availability and eventual consistency more comfortably than most in this series
As covered in this series' System Design guide's CAP theorem discussion, most of the capstone systems in this series (payments, orders, surveillance) deliberately favor consistency at some cost to availability, given the stakes of getting a write wrong. A URL shortener is one of the clearer cases in the other direction: a newly created mapping propagating to the cache (Section 6) a few hundred milliseconds after creation, or a click count (Section 9) being briefly stale, costs essentially nothing — favoring availability and low latency on the redirect path is the correct default here, not a compromise.
Where strong consistency is still genuinely worth it
The CREATE operation itself (Section 4's collision check, Section 7's custom
alias uniqueness) needs strong consistency at write time — two concurrent
requests for the SAME custom alias must not both succeed, per Section 7's
"no silent overwrite" principle.
The one place this guide does insist on strong consistency is exactly the place Section 7 already identified — alias/code uniqueness at creation time — since a race allowing two different long URLs to claim the same code would be a genuine correctness bug, not a tolerable staleness; everything downstream of a successfully created, unique mapping can relax into eventual consistency without real cost.
13. Scaling the System
Applying this series' System Design guide's building blocks, with shortener-specific emphasis
Sharding the mapping store (per this series' Database Sharding guide): by
short code (consistent hashing) — a natural, even distribution given codes
are effectively random or evenly-distributed by design (Section 4)
Multi-layer caching (per this series' Caching guide): a local, in-process
cache for the hottest handful of codes PLUS a shared distributed cache
(Redis) behind it, reducing network hops for the most popular redirects further still
CDN-level redirect caching (per this series' CDN guide): for 301 redirects
specifically (Section 6), a CDN can cache and serve the redirect without
the request ever reaching the shortener's own infrastructure at all
Every technique from this series' System Design guide applies here, with the caveat that, unlike several other capstone guides in this series, nearly every technique here is safe to apply aggressively — Section 12 already established that this system tolerates staleness comfortably, so caching layers can be added generously without the careful, scoped exceptions those other guides required.
Read replicas for the (comparatively rare) cache-miss path
Because the mapping store (Section 3) only serves cache misses in steady
state, read replicas (per this series' PostgreSQL/NoSQL replication guides)
are a straightforward, low-risk scaling lever here — there's no analog to
this series' Order Management guide's caution about routing correctness-
critical writes to a replica, since misses are reads by definition.
Given how comparatively small the direct mapping-store read volume is once caching (Section 6) is working as intended, read replica scaling here is close to a solved problem — worth noting as a contrast to the more careful, scoped consistency trade-offs this series' other capstone guides require.
14. Observability for a URL Shortener
Every guide in this series' observability trio, applied with latency-specific stakes
Structured logs (per this series' Structured Logging guide): creation events,
redirect cache hits/misses, rate-limit rejections — sampled at high redirect
volume, since logging every single redirect synchronously would itself
threaten the latency guarantee Section 1 centers this whole design around
Distributed tracing (per this series' Distributed Tracing guide): useful
primarily for diagnosing the CREATE path's occasional slowness (a collision
retry storm, Section 4) — less critical on the redirect path, which should
be simple and fast enough to rarely need deep tracing to diagnose
Metrics (per this series' Prometheus/Grafana guide): cache hit rate (the
single most important number this system produces about itself), redirect
p99 latency, code-generation collision rate, rate-limit rejection rate
Every technique from this series' observability guides applies directly, with one shortener-specific addition worth stating explicitly: cache hit rate is the closest thing this system has to a one-number health summary, given Section 1's framing — a hit rate trending downward is an early, leading indicator of a redirect-latency problem well before p99 latency itself visibly degrades.
Alerting on cache health and abuse-pattern symptoms
# Per this series' Prometheus/Grafana guide's symptom-based alerting principle
(sum(rate(cache_hits_total[5m])) / sum(rate(cache_requests_total[5m]))) < 0.95
A cache hit rate dropping below an established baseline, or a sudden spike in create-endpoint rate-limit rejections (a possible abuse wave, per Section 10), are exactly the kind of symptoms this series' Prometheus/Grafana guide argues alerts should be built around — the two are worth distinguishing clearly in dashboards, since one is an infrastructure health signal and the other is a security/abuse signal, and conflating them slows down the right response to either.
15. Common Pitfalls
| Pitfall | Why it hurts | Better approach |
|---|---|---|
| Treating the mapping store as the primary redirect-time read path | Redirect latency scales with database load instead of cache performance, undermining the system's core value | Cache-aside architecture (Section 6); the mapping store serves misses and writes, not the hot path |
| A single, uncoordinated shared counter for code generation | Becomes a write bottleneck and single point of failure at real creation volume | Random-with-collision-check for simplicity, or a Snowflake-style distributed counter at higher volume |
| Silently substituting a different code when a custom alias collides | Confusing, unexpected product behavior — the user didn't get what they asked for with no clear signal | Reject with a clear "alias taken" response; never silently substitute |
| Checking expiration only against the mapping store, not the cached value | A cached entry can outlive its own TTL and serve an expired link as if still valid | Check expires_at against the cached value itself at redirect time, not just at the source-of-truth layer |
| Writing click analytics synchronously on the redirect path | Directly adds latency to the one operation this system's entire value depends on being fast | Fire-and-forget event publish to an async pipeline (Section 9); redirect never waits on analytics |
| No rate limiting (or one shared limit) on link creation | An open creation endpoint is a well-known phishing/spam abuse vector | Strict, endpoint-specific rate limiting on creation, separate from the redirect endpoint's own limits |
| Skipping malicious-URL checks at creation time | The service becomes an unwitting phishing/malware distribution vector | Check submitted destinations against an established URL reputation service before and after creation |
| Logging or storing raw long URLs and IPs indiscriminately | Long URLs can embed sensitive tokens; raw IPs are more personal data than most analytics use cases need | Scope access to raw URLs narrowly; minimize analytics data to hashed/coarse fields (Section 11) |
Quick Reference Table
| Concept | Purpose |
|---|---|
| Simple, minimal domain model | Matches the genuinely simple core lookup problem, avoiding unneeded ceremony |
| Key-value mapping store | Fits the dominant single-key-lookup access pattern directly, per this series' NoSQL guide |
| Random-with-retry or counter-based code generation | Two legitimate strategies trading implementation simplicity against guaranteed uniqueness |
| Cache-aside redirect path | The system's central architectural decision, given its extreme read/write ratio |
| Explicit collision rejection for custom aliases | Respects the user's intentional namespace claim rather than silently substituting |
| TTL as a first-class, optional mapping property | Supports both permanent and time-limited links without a disruptive later retrofit |
| Fire-and-forget click analytics | Keeps the redirect path's latency guarantee unconditional, accepting bounded, low-stakes data loss |
| Endpoint-specific rate limiting + URL reputation checks | Defends the open creation endpoint against phishing and spam abuse |
Conclusion
A URL shortener takes every general system design technique covered throughout this series and applies it to a problem whose apparent simplicity is exactly what makes it such a good teaching example — because nearly every one of its handful of moving parts turns out to hinge on a genuine, well-reasoned trade-off rather than a single obviously correct answer. The design that actually holds up rests on a small number of deliberate choices: a cache-first redirect path built around this system's extreme read/write skew; a code generation strategy chosen deliberately between simplicity and guaranteed uniqueness; expiration and cleanup handled as first-class, background concerns rather than retrofitted; click analytics kept strictly asynchronous so it can never threaten the one latency guarantee the whole system exists to provide; and abuse prevention treated as a default requirement for any open, public-facing creation endpoint, not an afterthought.
Nearly every architectural pattern covered elsewhere in this series shows up here in service of that bar — Caching's cache-aside pattern as the true centerpiece, Distributed ID Generation's trade-off between coordination and collision handling, Event-Driven Architecture's fire-and-forget publishing for low-stakes analytics, and Rate Limiting and Abuse Prevention protecting the one endpoint this system can't afford to leave open. A URL shortener is, in that sense, less a distinct discipline from everything else in this series than the place where its cumulative lessons about caching, trade-off awareness, and matching design rigor to actual stakes come together in their most compact, instructive form.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the cache-hit-rate-dropped-and-nobody-noticed-until-p99-spiked incident that turned out to matter far more than a clever code generation scheme ever should.
Top comments (0)