~12 min
Every classic system design prompt is really the same exercise repeated: scope it, estimate it, then let the numbers choose the architecture, rather than reaching for a familiar tool first. For a URL shortener, the functional list from module one still holds — create a short link, redirect a short code, optionally a custom alias or expiry — and the numbers estimated there (100 million creates a day, redirects far outnumbering creates, on the order of 90 TB over five years) are the ones that decide everything from here. The non-functional target worth pinning down explicitly: redirects need to answer in well under 200 ms, because a slow redirect feels broken in a way a slow create call doesn't.
The data model is close to the simplest one this course covers: a single links table keyed by code, holding long_url, owner_id, created_at, and an optional expires_at — normalized, since there's no relationship complex enough to justify copying data around. The interesting design decision is how code gets generated. Encoding an auto-incrementing integer ID in base62 gives short, collision-free codes with no coordination between servers, at the cost of codes that reveal creation order; generating a random string and checking it against the table for a collision needs a uniqueness check on every create, which is cheap at this table's size but adds a round trip. The API from module one still applies directly: POST /links creates, GET /r/{code} redirects with a 301 or 302 status per RFC 9110's redirection semantics, and code is exactly the index this course covered earlier — the one column every redirect reads by.
With redirects dominating traffic by orders of magnitude, this design earns its keep from caching: a cache-aside cache in front of links, keyed by code, turns the overwhelming majority of redirects into a cache hit that never touches the database at all, and because a link's long_url essentially never changes after creation, a long TTL (or no TTL at all, invalidated only on explicit delete) is safe rather than reckless — this is exactly the read-heavy, rarely-written case the caching module's read/write framework was built for. The database underneath is relational, chosen the same way the storage module framed the choice: no cross-row transaction is needed, but a unique constraint on code is exactly the kind of guarantee a relational schema enforces for free. At today's estimated scale a single primary with a read replica or two covers it; sharding by a hash of code is the fallback once write volume or table size outgrows that, not a day-one requirement.
Use a very long TTL (or skip expiry and invalidate only on explicit delete) — since the underlying value can't change out from under the cache, the usual staleness risk from a long TTL doesn't apply here.