Back
URL Shortener

URL Shortener

4/14/2025
3 min read
TL

Timothy Lee

Part time Data Scientist, Full Time Nerd...

Technology
System Design

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:

StorageAccess 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

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!