Among common system design interview prompts, the URL shortener stands out as one of the most beginner-friendly. It is not valuable because it is deeply complex, but because it offers a clean way to practice reasoning through a real system one layer at a time.
Product Requirement
Functional Requirements
- A user can submit a long URL and get back a shorter version
- Optionally, they may choose their own alias
- Optionally, they may set an expiration time
- When someone visits the short URL, they should be redirected to the original link
Non-Functional Requirements
- High Availability
- Low Latency Redirection
- Scalability — millions of URL mappings and redirects per day
- Reliability — Links must not break
There’s a clear asymmetry in how this system is used: reads dominate writes by a wide margin. A link might be created once but resolved repeatedly, often at a much higher frequency. This access pattern becomes a key driver behind decisions around caching strategy and data storage.
Design Setup
Data Model
- User (who created the link)
- Original URL (the full link)
- Short URL (the generated identifier)
API Design
Once the core entities are clear, the next step is to establish how the outside world communicates with the system.
This is where the API comes in. It defines the contract between clients and the backend, and getting that contract right early makes the rest of the design much easier to reason about.
To create a short link:
POST /urls { "long_url": "https://www.chillinterview.com/long-url", } -> { "short_url": "http://short/chill123" }
To handle redirection:
GET /{short_code}
The server responds with an HTTP redirect (typically 302), sending the user to the original URL.
High-level Design
With the requirements and interface in place, we can start assembling the system itself.
Rather than jumping straight to a fully developed architecture, it’s usually cleaner to layer the design gradually, solving one core need at a time.
1) Generate a short link from a long URL
Once a user submits a full URL, the system must produce a shorter representation that is both convenient to share and safe to use as a unique reference within the system.
At a high level, the system might look like this:

When a user wants to shorten a URL, the process begins with a request to the system containing the original link along with any optional parameters such as a custom alias or expiration time.
-
Once the request reaches the backend, the first step is basic validation—ensuring the input is a well-formed URL. This can be handled through standard libraries or lightweight checks.
-
At this point, the system may choose to check whether the same URL has already been shortened before. Returning an existing short code can save storage and avoid duplication. That said, many real-world implementations deliberately skip this optimization. Allowing multiple short links for the same destination provides more flexibility—for example, different users might want their own aliases, separate expiration rules, or independent tracking.
-
In other words, avoiding deduplication slightly increases storage usage, but enables a more flexible product experience.
-
-
If the input passes validation, the next step is to produce the identifier that will represent this URL in shortened form
- For now, we can treat that as an abstract operation: the system takes a long URL and returns a compact code. The exact strategy for generating that code is an important design topic on its own, so it makes sense to defer that discussion until later.
- If the user provides a custom alias, the flow is slightly different. In that case, the system can attempt to use the alias directly, as long as it is still available. One subtle issue here is namespace collision: manually chosen aliases should not overlap with automatically generated codes in the future. A clean way to avoid that is to separate the two namespaces, either through reserved prefixes or by enforcing different naming rules for generated and user-defined values.
-
Once the identifier is ready, we persist the mapping in storage, associating the short code (or custom alias) with its original URL and any relevant metadata such as expiration.
-
At that point, the shortened link can be sent back to the client.
2) Clicks the short link should end up at the original URL.

Once a request hits our system, resolving the destination involves a simple lookup-and-redirect sequence:
- The browser issues a request to our service using the short code (e.g.,
GET /chill123). - The server extracts the identifier and queries the storage layer for a matching record.
- If a valid entry exists, the system verifies that it hasn’t expired. If the link is no longer valid, a
410 Goneresponse is returned instead. - Otherwise, the original URL is retrieved, and the server responds with an HTTP redirect, sending the user to the intended destination.
To keep the system tidy, expired entries can be handled asynchronously—for example, via a periodic cleanup job, or simply left in place with their expiration metadata.
More importantly, the cache layer should be aligned with these expiration rules. Setting the TTL to match (or be slightly shorter than) the URL’s lifetime ensures outdated mappings are naturally evicted without additional intervention.
Choosing a Redirect Strategy
When sending users from a short link to the original destination, we have a couple of HTTP redirect options, each with slightly different behavior.
Option 1: Permanent Redirect (301): A 301 response signals that the destination is stable and not expected to change. Because of this, browsers often cache the result, so future visits may go directly to the final URL without contacting our service again.
The response back to the client looks like this:
HTTP/1.1 301 Moved Permanently Location: https://www.chillinterview.com/redirect...
Option 2: Temporary Redirect (302): A 302 response implies that the destination may change over time. Because of this, browsers typically avoid caching the redirect, ensuring that each request is resolved through our system rather than being short-circuited on the client side.
The response back to the client looks like this:
HTTP/1.1 302 Found Location: https://www.chillinterview.com/redirect...
What Happens in Practice
From the user’s perspective, the redirect is completely transparent. The browser handles the transition automatically, so clicking a short link simply lands them on the final page with no visible intermediate step.
Why 302 Is Typically Preferred
In a URL shortening system, retaining control over request handling is often more valuable than browser-level caching. A temporary redirect supports that by:
allowing the destination to be updated or invalidated over time avoiding cached responses that could become stale on the client side ensuring every request flows through our service, which enables tracking and monitoring
Deep Dives
At this stage, the system is already good enough to meet the basic product requirements: users can create short links, and those links can resolve back to their original destinations.
But a workable design is not the same as a production-ready one.
If we now shift our attention to the non-functional goals—things like scalability, latency, and reliability—it becomes clear that several parts of the system deserve a closer look. In particular, we still need to think more carefully about collision avoidance, performance under load, and how the design holds up as traffic grows.
1) Generating unique short codes
So far, we’ve treated short-code generation as a black box. That was fine for getting the high-level architecture in place, but eventually we need a concrete strategy.
A good solution here needs to balance a few competing goals:
- every generated code must be unique
- the identifier should stay compact and human-friendly
- generating new codes should remain cheap and fast
Solution: Counter-Based IDs + Base62 Encoding
A straightforward way to avoid collisions is to rely on a monotonically increasing counter. Instead of generating random identifiers, the system simply assigns the next available number to each new URL.
On its own, the raw counter value isn’t ideal—it grows quickly and isn’t very compact. To address this, we can encode the numeric ID using Base62, which produces a much shorter, URL-friendly string.
Why Redis Works Well Here
Managing this counter in a distributed system introduces a coordination problem: every instance needs to agree on the next available value.
This is where Redis fits naturally. It provides an atomic increment operation (INCR), ensuring that each request receives a distinct value without conflicts. Since Redis processes commands sequentially, concurrent requests are handled safely—each increment is guaranteed to return a unique result.
For example, even if two requests arrive at the same time, one might receive 1000 while the other gets 1001, with no overlap.
Why This Approach Is Effective
This design has a few practical advantages:
- No collision handling needed — uniqueness is guaranteed by construction
- High performance — increment + encoding is lightweight and fast
- Scales well — works cleanly across multiple service instances
- Reversible mapping — encoded IDs can be decoded if needed for internal lookups

Tradeoffs and Limitations
While a counter-based approach is simple and effective, it introduces a few practical concerns that are worth calling out.
A. Coordinating a Global Counter
In a distributed setup, multiple service instances need access to the same sequence of IDs. This creates a coordination challenge, since all writers must stay consistent on what the “next” value is.
A centralized counter (e.g., backed by Redis) solves this, but it also becomes a shared dependency that the system must rely on. We’ll revisit how to scale this safely in a later section.
B. Predictable Identifiers
Because IDs are generated sequentially, the resulting short codes follow a predictable pattern. This makes it possible for someone to iterate through values and discover existing links.
One way to mitigate this is to transform the counter before encoding—for example, by applying a reversible mapping with a private key. Alternatively, this may be an acceptable tradeoff depending on the product, since many short links are intended to be publicly accessible anyway.
C. Growth of Code Length Over Time
Another natural concern is whether the identifiers will become too long as the system grows.
In practice, Base62 encoding is surprisingly efficient. Even at large scale, the resulting strings remain compact. For instance, encoding a value on the order of hundreds of millions still produces a short, fixed-length string: 850,000,000 → "2K8x9Z"
This means we can support billions of URLs while keeping identifiers within a small number of characters. Only when we reach the next order of magnitude does the length increase by one.
To put it differently:
- 62⁶ already supports tens of billions of unique values
- 62⁷ pushes capacity into the trillions
So although the code length does grow, it does so slowly enough that it’s rarely a practical limitation.
2) Keeping Redirects Fast
Once the system scales, lookup speed becomes a critical concern. Every redirect sits directly on the user’s request path, so even small delays can noticeably impact the experience.
If we rely purely on the database, each lookup could degrade into scanning large portions of stored data to find the matching entry. As the dataset grows into the millions or billions, this quickly becomes impractical—full scans are simply too slow to support real-time traffic.
Solution: Introducing a Caching Layer
To keep redirect latency low at scale, we can add a caching layer between the application and the database.
The idea is simple: instead of resolving every request against persistent storage, we keep frequently accessed mappings in memory. When a request comes in, the system first attempts to resolve the short code from the cache. If the entry is present, the lookup completes almost instantly. Otherwise, the request falls back to the database, and the result is then cached for subsequent accesses.
This pattern effectively turns most reads into in-memory operations, with the database acting as a fallback rather than the primary lookup path.
Why This Makes a Huge Difference
The performance gap between memory and disk is substantial. Accessing data from RAM happens on the order of nanoseconds, while even fast SSDs operate several orders of magnitude slower, and traditional disks are slower still.
In practical terms:
- Memory can handle millions of reads per second
- SSDs are typically limited to tens of thousands of operations per second
- HDDs fall even further behind
This difference is exactly why caching is so effective here—by serving the majority of requests from memory, we can dramatically reduce latency and handle far higher traffic volumes.
3) Scaling to Billions of URLs
At this point, most of the heavy lifting for read scalability is already in place thanks to the caching layer. The next question is how the system behaves as the dataset and traffic continue to grow—especially on the write path and storage layer.
Estimating Storage Requirements
Before choosing any infrastructure, it helps to sanity-check the scale. Each record in our system includes:
- a short code
- the original URL
- timestamps and optional metadata (e.g., alias, expiration, ownership)
Even with a generous estimate of a few hundred bytes per entry, storing billions of mappings results in data on the order of hundreds of gigabytes. In practice, this is not particularly large by modern standards. A single well-provisioned database instance can comfortably handle this size, and we only need to consider sharding if growth significantly exceeds expectations.
Choosing a Database
Given that most read traffic is absorbed by the cache, the database is primarily responsible for writes and occasional cache misses. Write throughput is relatively modest—new URL creation happens far less frequently than lookups—so the system doesn’t require anything exotic here. A standard relational database or managed key-value store will work just fine.
In an interview setting, the best choice is usually the one you understand well (e.g., Postgres, MySQL, or a managed NoSQL solution).
Handling Failures
A more important concern than raw performance is availability. If the database becomes unavailable, the system should degrade gracefully. Common approaches include:
- Replication: maintaining multiple synchronized copies so traffic can be redirected on failure
- Backups: periodically snapshotting data for recovery in worst-case scenarios
Both approaches add operational complexity, but they are essential for maintaining reliability at scale.
Scaling the Application Layer
Earlier, we noted that traffic is heavily skewed toward reads. This naturally suggests separating responsibilities at the service level:
- a read path dedicated to handling redirects
- a write path responsible for creating new short URLs
Decoupling these flows allows each side to scale independently. The read layer can expand aggressively to handle high request volume, while the write layer remains comparatively lightweight. Both services can then be horizontally scaled—adding more instances behind a load balancer—to distribute traffic and avoid bottlenecks.
The Counter Problem (Revisited)
However, scaling the write path introduces a subtle issue.
Since short codes are generated from a shared counter, all write instances must agree on a single global sequence. Without coordination, duplicate IDs would quickly appear.
To address this, we rely on a centralized counter service—commonly backed by Redis. Its atomic increment operation guarantees that each request receives a unique value, even under concurrency.
The flow becomes:
- write service requests the next counter value
- generates the short code
- persists the mapping
This keeps ID generation consistent across all instances while maintaining high throughput.

Optimizing Counter Access
At first glance, relying on a centralized counter introduces an extra network hop for every write. The natural question is whether this becomes a bottleneck. In practice, it usually doesn’t. Network latency is small relative to the overall request path, and the write volume in a URL shortener is typically low compared to read traffic. That said, we can still optimize this path to reduce unnecessary coordination.
Reducing Contention with Batching
Instead of requesting a new counter value for every URL, each write instance can pre-allocate a range of IDs and consume them locally.
The flow looks like this:
- A write node requests a block of IDs (e.g., a few thousand) from the counter service
- The counter service advances its global value by that amount and returns the starting point
- The node generates short codes locally using that reserved range
- Once the range is exhausted, it requests another block
This approach significantly reduces the number of network calls while preserving global uniqueness. It also lowers pressure on the centralized counter service.
Making the Counter Highly Available
Since the counter is a shared dependency, it needs to be resilient. This is typically handled through mechanisms like leader-follower replication and automatic failover (e.g., Redis Sentinel or clustered deployments). Given the relatively low write rate, even a modest setup can comfortably handle the workload, especially when batching is applied.
Extending to Multi-Region
In a multi-region setup, a single global counter can introduce latency and coordination overhead. A common strategy is to partition the ID space across regions. For example, each region is assigned a distinct range, allowing it to generate IDs independently without cross-region synchronization. Writes stay local, while reads can still be served globally via distributed caches.
Handling Failure Edge Cases
If the counter service fails before fully propagating its latest state, some allocated IDs may be lost. In this system, that’s acceptable—we only require uniqueness, not a perfectly continuous sequence. As a final safeguard, the database can enforce a uniqueness constraint on the short code, ensuring that any unexpected duplication is caught at the storage layer.
Conclusion
A URL shortener may look simple on the surface, but designing it at scale introduces meaningful challenges around latency, uniqueness, and reliability.
The key ideas are straightforward:
- optimize for read-heavy traffic with caching
- ensure efficient and unique ID generation
- separate read and write paths for better scalability
- design with failures in mind
Ultimately, this problem isn’t about the architecture itself, but about how you reason through tradeoffs and evolve a system step by step—that’s what makes it such a powerful interview question.