Introduction
A URL shortener seems simple on the surface—compress long links into compact codes and redirect users on demand. But when you start designing a URL shortener: a practical guide for developers would reveal that this small problem contains nearly every core system design concept: unique identifier generation, caching strategies, asynchronous processing, and trade-offs between consistency and measurement. Building one teaches you how to handle read-heavy workloads, optimize the critical path, and make deliberate choices about durability versus speed.
We've learned these lessons through production experience. Let's walk through a real-world approach.
Requirements: Understanding the Read-Write Asymmetry
Before you design anything, nail down the shape of the problem. A URL shortener has three main operations:
- Shorten: Accept a long URL, generate a short code, store the mapping.
- Redirect: Look up the code, return the original URL with a redirect response.
- Track clicks: Measure how many times each link is used.
The traffic pattern is heavily skewed toward reads. Redirect requests outnumber shortening requests by roughly 100 to 1. This asymmetry shapes every decision that follows. You're not optimizing for write throughput; you're optimizing for redirect latency and read availability under load.
At scale, a single popular shortened link can receive thousands of requests per second. Your system must serve redirects in milliseconds while remaining resilient to database failures. A slow redirect degrades user experience immediately. Failed shortening requests are annoying but tolerable—failed redirects are unacceptable.
Generating the Key: Base62 Encoding
The short code itself deserves thought. A naive approach might use a UUID or random string, but you want something compact and URL-safe. Base62 encoding (digits 0-9, lowercase a-z, uppercase A-Z) gives you 62 possible characters per position, providing excellent density without special characters that complicate URLs.
The math is straightforward. A 7-character Base62 string yields 62^7 ≈ 3.5 trillion possible codes—more than enough for most services. Bitly, for comparison, uses similar logic.
The most reliable approach is to maintain an auto-incrementing ID in your database, then encode it to Base62:
function encodeBase62(num: bigint): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let encoded = '';
while (num > 0n) {
encoded = alphabet[Number(num % 62n)] + encoded;
num = num / 62n;
}
return encoded || '0';
}
function decodeBase62(str: string): bigint {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let num = 0n;
for (const char of str) {
num = num * 62n + BigInt(alphabet.indexOf(char));
}
return num;
}Why an auto-incrementing ID instead of random generation? Collisions are simpler to handle—you just retry with the next ID. You also get clustering benefits in your database indexes, and the ID itself can encode useful metadata (timestamp, shard ID, sequence within that shard) if you're running a distributed system. UUID-based approaches require collision detection logic and don't compress as well.
The Hot Path: Redirect Latency
Redirects are your hot path. They must be fast. The typical flow looks like this:
- User clicks a shortened link.
- Your server receives the request for
/abc123. - You look up
abc123to find the original URL. - You return a redirect response (301 or 302).
Database latency directly impacts user experience. A single query on every redirect adds 10-50 milliseconds of overhead. Under heavy load, database connections become contention points. The solution is caching.
Redis as the cache layer is standard practice. Store the mapping slug → original_url in Redis with a generous TTL (24-48 hours, or indefinite for popular links). Your redirect handler first checks Redis; on a cache miss, it queries the database and populates Redis in the background.
async function handleRedirect(slug: string): Promise {
// Try cache first
const cached = await redis.get(`url:${slug}`);
if (cached) {
return cached;
}
// Fall back to database
const url = await db.query(
'SELECT original_url FROM links WHERE slug = $1',
[slug]
);
if (!url) {
throw new NotFoundError();
}
// Populate cache for future requests
await redis.setex(`url:${slug}`, 86400, url.original_url);
return url.original_url;
}At Expona scale, we cache aggressively. Popular links live in Redis permanently. Cold links miss occasionally and hit the database, but because reads dominate, the cache hit ratio climbs above 99% quickly.
Consider a CDN for truly massive scale. A CDN edge node can serve redirects even faster than your origin servers. Some CDNs allow you to run edge logic (returning cached responses without hitting origin), turning geographic distribution into a latency advantage. This matters when your users are global and you want sub-100-millisecond redirects everywhere.
Analytics Without Slowing Redirects
Tracking clicks feels essential, but it creates a dangerous problem: if you block the redirect to write analytics to the database, you've just added latency to the hot path. Users experience slower redirects. That's unacceptable.
The solution is asynchronous processing. Publish a click event (slug, timestamp, user agent, IP) to a message queue—Kafka, RabbitMQ, or AWS SQS all work. Your redirect handler publishes and returns immediately; a separate worker consumes events and writes them to analytics storage.
async function handleRedirect(slug: string, req: Request): Promise {
// Synchronous redirect
const url = await getOriginalUrl(slug);
// Fire-and-forget analytics
await queue.publish('click_events', {
slug,
timestamp: Date.now(),
userAgent: req.headers['user-agent'],
ip: req.ip,
});
return url;
}This decouples the critical path from the analytics pipeline. If analytics processing is slow or the queue is backing up, users still get fast redirects. If the queue is down, you lose analytics but redirects continue working. That's the right trade-off—redirect availability is non-negotiable; analytics is a nice-to-have.
Workers reading from the queue can batch writes to your analytics database or data warehouse, making the overall system more efficient. You could store individual clicks in a time-series database like ClickHouse or aggregate them into hourly summaries in PostgreSQL. The latency budget is generous because analytics are not real-time.
301 Redirect vs. 302: A Strategic Choice
HTTP offers two redirect status codes, each with different semantics and consequences:
- 301 (Moved Permanently): Browsers and clients cache the redirect locally. Subsequent clicks on the same link bypass your server entirely and go directly to the original URL.
- 302 (Found): Redirects are not cached. Every click hits your server, ensuring every redirect is measurable and traceable.
The choice depends on your priorities.
Use 301 to reduce load. Once a browser caches the redirect, you've eliminated future requests for that link. This dramatically lowers server traffic and database/cache load. For a high-volume service, 301 responses save significant infrastructure costs. Users also benefit—cached redirects are instantaneous from their perspective.
Use 302 to maintain analytics accuracy. If you return 302, every click reaches your server and gets logged. Analytics are precise and real-time. You capture URL changes (if the short link's target is updated) immediately. You also control the user experience—you could A/B test destinations or notify users before redirecting.
In practice, we recommend 301 for public links with stable targets and 302 for internal links, testing environments, or cases where you need audit trails. Some services use both: 301 for users on fast networks (reducing load) and 302 for others (ensuring measurement). Be transparent in your code and documentation about which status you're using.
Common Mistakes
Generating random slugs without collision handling. It seems simpler than an auto-incrementing ID, but collisions require retry logic and contention detection, adding complexity. Stick with deterministic encoding from sequential IDs.
Blocking redirects on analytics writes. Any synchronous database write in the redirect handler will show up in user latency. Always decouple analytics from the critical path.
Underestimating cache warming. If a popular link expires from cache and thousands of concurrent requests hit the database, you risk cache stampede. Implement cache locks or use generous TTLs for popular links.
Forgetting about URL validation. Always validate and normalize incoming URLs before storing. Malformed URLs, extremely long URLs, or data URLs can cause downstream problems. Also consider rate-limiting the shortening endpoint to prevent abuse.
Choosing the wrong redirect status code without reasoning. Make a conscious decision based on your analytics requirements and load profile, not habit. Document it.
Key Takeaways
- Understand your read-write asymmetry: redirects are 100x more frequent than shortening, so optimize for redirect latency.
- Encode an auto-incrementing ID to Base62 for compact, collision-free short codes without random generation overhead.
- Cache redirects aggressively in Redis; a 99%+ cache hit ratio is achievable and necessary.
- Decouple analytics from the redirect hot path using asynchronous queues; analytics are valuable but not critical to user experience.
- Choose 301 or 302 deliberately: 301 reduces load and saves costs, 302 preserves analytics accuracy—pick based on your priorities.
- Plan for scale from the start: geographic distribution, cache invalidation, and database sharding all matter when a single link receives thousands of requests per second.






Comments
Loading comments…