
URL Shortener
Timothy Lee
Part time Data Scientist, Full Time Nerd...
I Built My Own URL Shortener (Like bit.ly). Here's How.
We've all used bit.ly or tinyurl before — but have you ever tried building one from scratch?
Inspired by real-world system design interviews, I decided to build a full-stack URL shortener: short.timooothy.me. I implemented two versions — one in TypeScript with Hono + Next.js, and another in Go — both performant and production-ready. But before diving into code, let's talk system design.
System Design Overview
To design a robust short URL service, we must identify:
Functional Requirements:
Users should be able to submit a long URL and get back a shortened version.
They can optionally specify an expiry date. If omitted, the default expiry is 7 days.
Accessing the short URL should redirect to the original URL.
Non-Functional Requirements:
- Low-latency redirection — no one likes a slow redirect.
- Scalability to support billions of shortened URLs.
- High availability over consistency (CAP theorem): it's okay if a short URL takes a moment to propagate, but redirects must work reliably.
Core Entities
- User (optional for future auth/rate-limiting)
- OriginalURL
- ShortenedURL
- ExpirationDate
We expose a RESTful API for URL creation and redirection.
Creating Short URLs
POST /urls
Request:
{
"url": "https://sg.linkedin.com/in/timooothy",
"expiresAt": "2024-12-31T00:00:00Z"
}
Response:
{ "shortCode": "http://short.timooothy.me/abc123" }
Redirecting Users
GET /abc123
Returns:
HTTP 302 Found → https://sg.linkedin.com/in/timooothy
Why 302 instead of 301?
Since short URLs may expire or be updated, using 302 (temporary redirect) avoids caching issues in browsers and CDNs.
Deep Dive: Design Decisions
1. Base62 Encoding
To generate short, collision-free codes, I used base62 encoding on a global counter stored in Redis.
const currentCount = await redis.incr("globalCount");
const shortCode = encodeBase62(currentCount); // yields "abc123"
With 6-character codes:
62^6 = 56.8 billion combinations
This is more than sufficient, fulfilling the non-functional requirement.
2. Fast Redirects with Caching
Instead of hitting the database every time, we:
- Try to read from Redis
- If not found, fallback to DB and cache it
if (await redis.exists(shortCode)) {
return redirect(cachedUrl, 302);
} else {
const result = await prisma.url.findUnique(...);
await redis.set(shortCode, result);
return redirect(result.url, 302);
}
Memory vs Disk Access Times:
Storage | Access Time |
---|---|
Memory (Redis) | ~0.1 µs |
SSD | ~0.1 ms |
HDD | ~10 ms |
That's a 100x+ difference using in-memory cache like Redis.
3. Rate Limiting with Sliding Window
To prevent abuse, each IP can only shorten up to 10 URLs per day:
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "86400 s"),
});
If you hit the limit:
{
"error": "Your daily rate limit has exceeded. Please try again tomorrow."
}
Simple, fair, and scalable.
Codebase
- TypeScript + Next.js + Hono API
🔗 GitHub – url-shortener - Golang Version (Echo + Redis + Postgres)
🔗 GitHub – url-shortener-go
Highlighted Code Snippets
Rate Limiting (Hono)
const { success } = await ratelimit.limit(ip);
if (!success) return c.json({ error: "Rate limit exceeded" }, 429);
Short Code Generation
const currentCount = await redis.incr("globalCount");
const shortCode = encodeBase62(currentCount);
Redirect Logic (Go)
if val, err := redis.Get(ctx, shortCode).Result(); err == nil {
return c.Redirect(http.StatusTemporaryRedirect, cachedUrl)
}
DB Fallback + Caching (Go)
db.QueryRow(`SELECT url, expiresAt FROM Url WHERE shortCode = $1`)
redis.Set(ctx, shortCode, data, ttl)
return c.Redirect(http.StatusTemporaryRedirect, url)
Conclusion
This was a fun and challenging full-stack project that brought together system design, caching, databases, and rate limiting — all in a clean API-driven architecture.
Check out the repo, star it ⭐, or shoot me a message on LinkedIn!