Designing a URL Shortener: From Requirements to Production Trade-offs
A practical walkthrough of how I would structure a URL-shortener system-design discussion, from requirements and data modelling to redirects, caching and abuse controls.
- System design
- API design
- PostgreSQL
- Caching
A URL shortener sounds simple: accept a long URL, return a short one, and redirect visitors later.
That simplicity makes it a useful system-design exercise. The difficult part is not writing the redirect endpoint. It is deciding what the first version must guarantee, where correctness matters, and which complexity should wait until there is evidence that it is needed.
This is how I would approach the problem in a system-design discussion.
1. Clarify the product before drawing the architecture
I would start by confirming the smallest useful scope.
Required for the first version
- A user submits a valid HTTP or HTTPS URL.
- The system generates a unique short code.
- Visiting the short URL redirects to the original URL.
- The mapping remains available after a restart or deployment.
- Every short code resolves unambiguously to at most one active mapping.
Questions that change the design
- Can users choose a custom alias?
- Can links expire?
- Can a destination be edited later?
- Is authentication required?
- Do we need click analytics?
- What traffic should the system support?
- Are links publicly accessible, unlisted, or protected by authentication?
For an interview-sized first version, I would support creation and redirection, then describe custom aliases, expiry and analytics as extensions.
2. Keep the critical path small
Read the architecture as text
- Single application: one Next.js deployment contains the creation UI, the
POST /api/linkshandler and the short-link redirect route. - Create: a link creator submits an original URL. The Next.js handler validates it, generates a code and stores the mapping in PostgreSQL.
- Redirect: a visitor reaches the Next.js redirect route. The application checks Redis first and queries PostgreSQL on a cache miss, then populates the cache.
- Analytics: the redirect route publishes a click event to a queue. A separate worker consumes it asynchronously, so analytics processing does not delay the redirect response.
There are two different workloads:
- Create path — validates a URL, generates a code and writes a mapping.
- Redirect path — reads a mapping and returns a redirect as quickly as possible.
The redirect path is normally more sensitive because every extra dependency adds latency and another failure point. Analytics should therefore happen asynchronously rather than blocking the redirect.
3. Define the API contract
A minimal create endpoint could be:
POST /api/links
Content-Type: application/json
{
"url": "https://example.com/a/very/long/path"
}
Successful response:
{
"code": "aZ91kQ",
"shortUrl": "https://sho.rt/aZ91kQ"
}
The redirect endpoint is public:
GET /aZ91kQ
Possible responses:
302 Foundwith aLocationheader when destinations may change301 Moved Permanentlywhen mappings are immutable and long-term caching is desirable404 Not Foundwhen the code does not exist410 Gonewhen the link existed but has expired
I would begin with 302 unless the product explicitly guarantees immutable destinations. It gives the system more control if links need to be corrected, disabled or moderated later.
4. Use a small, explicit data model
A PostgreSQL table could begin with:
CREATE TABLE links (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(16) NOT NULL UNIQUE,
original_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NULL,
created_by BIGINT NULL
);
The most important constraint is the uniqueness requirement on code. Application-level checks alone are not sufficient because two requests may generate the same candidate at the same time. In PostgreSQL, the UNIQUE constraint automatically creates the supporting unique index.
The redirect query stays simple:
SELECT original_url, expires_at
FROM links
WHERE code = $1;
The optional created_by field would only be populated if authenticated link ownership is part of the product scope.
5. Choose the short-code strategy deliberately
| Strategy | Strength | Trade-off |
|---|---|---|
| Random Base62 | Large, shareable namespace; database constraint handles collisions. | Retry on an unlikely collision. |
| Database sequence + Base62 | Simple uniqueness and compact codes. | Sequential codes are easier to enumerate. |
| Hash of original URL | Deterministic for a given input. | Truncation can collide; identical URLs need not share a link. |
For the first version, I would use a random Base62 code and let the database unique constraint protect against collisions.
Base62 uses:
a-zA-Z0-9
A six-character code provides approximately 56.8 billion possible values, although the appropriate length should account for expected volume, collision probability and retry rates.
Creation flow:
- Generate a random code.
- Attempt to insert it.
- If the unique constraint fails, generate a new code and retry.
- Limit retries and return an internal error if the unlikely collision loop cannot complete.
A database sequence encoded as Base62 is also practical, but sequential codes make enumeration easier. Hashing the original URL does not eliminate collisions once the hash is shortened, and it couples code generation to deduplication semantics that the product may not want.
6. Separate validation from fetching
The service should validate that the submitted destination:
- parses as a URL
- uses
httporhttps - stays within the maximum accepted length
- does not use unsupported schemes such as
javascript:ordata: - does not create an obvious self-referential redirect loop
A basic URL shortener does not need to fetch the destination during creation. Avoiding that network call keeps the create path smaller and avoids introducing unnecessary server-side request risks.
A production service would still need abuse controls because short links can hide phishing, malware or spam destinations.
7. Add caching only where it helps
The redirect workload is read-heavy, so a cache can reduce repeated database reads.
A simple read-through flow:
- Look up
codein Redis. - On a cache hit, redirect immediately.
- On a miss, query PostgreSQL.
- Cache the result with a suitable TTL.
- Return the redirect.
The application controls the fallback to PostgreSQL. Redis does not query the database itself, and PostgreSQL remains the source of truth.
I would not add Redis automatically for a small internal product. A unique index and a healthy PostgreSQL instance may already be enough. The cache becomes valuable when measurements show that redirect traffic or database load justifies another operational dependency.
If destinations can be edited, disabled or expired, the corresponding cache entry must be invalidated or updated during the write operation. The cache TTL acts only as a fallback, not as the primary consistency mechanism.
Negative caching can also help with repeated requests for unknown codes, but the TTL should be short so a newly created code does not remain incorrectly cached as missing.
8. Keep analytics off the redirect path
Click analytics are useful, but recording them synchronously can slow every redirect.
A better design is:
- Resolve the destination.
- Publish a lightweight click event to a queue.
- Return the redirect.
- Process analytics separately.
The event might contain:
- short code
- timestamp
- coarse referrer information
- privacy-safe location or device categories when permitted
The redirect should still succeed when the analytics consumer is delayed. Analytics are valuable, but they are not more important than the redirect itself.
9. Plan for failure and abuse
The first production review should cover:
Reliability
- timeout and fallback behaviour for database or cache failures
- retry behaviour for non-user-facing background work
- expired and disabled links
- backward-compatible database migrations
- health checks, redirect latency metrics and error-rate monitoring
Abuse controls
- rate limiting on link creation
- maximum URL length
- reserved custom aliases
- disabling malicious links
- reporting and moderation workflow
- protection against automated enumeration
- logs that avoid unnecessary personal data
Security
- validate schemes
- encode output correctly
- avoid open administration endpoints
- protect authenticated link-management routes
- keep secrets outside the repository
- apply least-privilege database access
10. Scale in measured steps
I would explain scaling as a sequence rather than designing the largest possible system on day one.
Stage 1 — Simple application
- one application service
- PostgreSQL
- indexed code lookup
- basic monitoring
Stage 2 — Read optimisation
- Redis read-through cache
- connection pooling
- separate create and redirect metrics
- rate limiting
Stage 3 — Higher traffic
- multiple stateless service instances
- load balancer
- asynchronous analytics queue
- cache replication or managed cache
- database read strategy based on measured load
Stage 4 — Global redirect traffic
- regional or edge caching
- careful cache invalidation
- globally unique code generation
- operational tooling for abuse and link takedowns
The important interview point is not to predict every future component. It is to show which measurement or product requirement would justify each one.
11. State the trade-offs clearly
My first-version decision would be:
- PostgreSQL as the source of truth
- random Base62 codes
- a database unique constraint with collision retry
302redirects while destinations remain changeable- no cache until traffic justifies it
- asynchronous analytics through a queue
- explicit validation and creation rate limits
This is intentionally simple. It gives the product a correct and maintainable base while leaving clear extension points for caching, analytics, expiry, custom aliases and global delivery.
12. How I would present it in an interview
I would use this order:
- Restate the goal.
- Confirm functional requirements.
- Ask about traffic, mutability, expiry and analytics.
- Write confirmed answers under headings such as users, requirements, constraints and scale.
- Draw create and redirect paths separately.
- Define the API and data model.
- Choose a code-generation strategy.
- Explain correctness through the unique constraint.
- Discuss caching only after the basic design.
- Cover failure, abuse and security.
- End with trade-offs and the next scaling step.
That structure matters as much as the final diagram. It helps the interviewer follow the reasoning and makes it easier to recover when a requirement changes.