
bit.ly/xY9) to long URLs and serve a permanent 301 redirect.If you google "how to build a url shortener," you'll find thousands of tutorials using random string generation and storing results in a local JSON file. But you are building a product, not a hackathon project. When searching for "how to build a scalable url shortener (like Bitly)," you need a system that survives DDoS attacks, handles millions of requests per second, and persists data correctly without hitting rate limits.
Most developers fail here because they focus on generating the short code instead of routing the traffic. In this guide, we will move past the basics and design an architecture capable of handling massive scale.
At its simplest level, a URL shortener is a two-way mapping:
https://very-long-domain/product?id=12345) and turns it into a short key (e.g., c4X9).c4X9, looks up the original URL, and tells the browser to go there using a 301 (Moved Permanently) status code.The "Magic" lies in two places:
"Never use sequential IDs (1, 2, 3, 4...) as your short URL slug."
Most tutorials use auto-incrementing IDs because they are easy to manage. However, this is a security and performance risk. If an attacker knows your site has 10,000 links, they can brute-force guess every URL in existence. Furthermore, sequential IDs leak traffic data (you can see how many people clicked the links in sequence).
The only way to build a system like Bitly is to use consistent hashing to distribute keys across different database servers (sharding) and randomization to prevent ID enumeration.
We need to compress a large number into a short string.
You need two main endpoints:
POST /api/shorten - Input a long URL, return the short code.GET /{short_code} - Perform the redirect logic.Store is not enough. To build a scalable url shortener like Bitly, you must optimize the "Read-Heavy" nature of this application.
URL_ID, Short_Code, Original_URL, Created_At, Click_Count. Use SQL for ACID compliance.example.com/c4X9.c4X9 exists in Redis's Key-Value store.
If your database becomes a bottleneck, split your data horizontally.
hash(short_code) % number_of_shards.We will write a utility to convert numbers to Base62.
import string
# Set of characters for encoding numbers 0-9, A-Z, a-z
ALPHABET = string.digits + string.ascii_letters
ALPHABET_REVERSE = {c: i for i, c in enumerate(ALPHABET)}
BASE = len(ALPHABET)
def encode(num, alphabet=ALPHABET):
"""Converts an integer to a Base62 string."""
if num == 0:
return alphabet[0]
encode = []
while num > 0:
num, rem = divmod(num, BASE)
encode.append(alphabet[rem])
return ''.join(reversed(encode))
def decode(string, alphabet=ALPHABET):
"""Decodes a Base62 string back to an integer."""
num = 0
for char in string:
num = num * BASE + alphabet_reverse[char]
return num
Here is a production-ready structure handling Redis lookups and database persistence.
const express = require('express');
const redis = require('redis');
const { Pool } = require('pg');
const app = express();
app.use(express.json());
// Initialize Connections
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });
const pgPool = new Pool({ connectionString: 'postgres://user:pass@localhost/db' });
// 1. Shorten URL Endpoint
app.post('/shorten', async (req, res) => {
const longUrl = req.body.url;
// Begin Database Transaction
const client = await pgPool.connect();
try {
await client.query('BEGIN');
// Check if URL already exists to avoid duplicates
const checkResult = await client.query('SELECT id FROM urls WHERE original_url = $1', [longUrl]);
let urlId;
if (checkResult.rows.length > 0) {
urlId = checkResult.rows[0].id;
} else {
// Insert new URL
const insertResult = await client.query('INSERT INTO urls(original_url) VALUES($1) RETURNING id', [longUrl]);
urlId = insertResult.rows[0].id;
}
// Encode ID to Base62
const shortCode = encode(urlId);
// Commit Transaction
await client.query('COMMIT');
// Increment Click Count (Optional for backend audit)
// await redisClient.incr(`counter:${shortCode}`);
res.json({
original_url: longUrl,
short_url: `https://yourdomain.com/${shortCode}`
});
} catch (err) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'Internal Server Error' });
} finally {
client.release();
}
});
// 2. Redirect Logic
app.get('/:shortCode', async (req, res) => {
const shortCode = req.params.shortCode;
// First, try Redis Cache
const cachedUrl = await redisClient.get(shortCode);
if (cachedUrl) {
// Increment click in background
redisClient.incr(`clicks:${shortCode}`);
return res.redirect(301, cachedUrl);
}
// If not in cache, hit PostgreSQL
const result = await pgPool.query('SELECT original_url FROM urls WHERE id = $1', [decode(shortCode)]);
if (result.rows.length === 0) {
return res.status(404).send('URL not found');
}
const originalUrl = result.rows[0].original_url;
// Store in Cache (TTL 1 hour to save memory)
await redisClient.setex(shortCode, 3600, originalUrl);
await redisClient.incr(`clicks:${shortCode}`);
// HTTP 301 is crucial for SEO. It tells Google the URL has moved permanently.
res.redirect(301, originalUrl);
});
app.listen(3000, () => {
console.log('Shortener running on http://localhost:3000');
});
Key Takeaway for Developers: Do not delete the record immediately when deleting the short URL. This breaks existing links for users who clicked the link in the past. At minimum, soft-delete (mark as
is_deleted=true) and version the URL records.
| Approach | Speed | Scalability | Consistency | Use Case |
|---|---|---|---|---|
| Database Only | Low (Disk I/O) | Low-Medium | High | Side projects, low traffic |
| Redis Only | High (In-Memory) | Low-Medium | Low (Data loss risk on crash) | High-frequency temporary links |
| Hybrid (Redis + SQL) | High | High | High | Production Apps (Bitly-like) |
COUNTER in Redis associated with the short key is sufficient for most platforms.To move from a "Simple Shortener" to a "Platform" (like Bitly), you must integrate:
my.cliKA.sh to their content.Is it safe to use a custom domain for a URL shortener? Yes, but mitigations are needed. Use cloudfare's DDoS protection or load balancers to prevent your custom domain from being blacklisted if bad actors abuse your service.
Does a URL shortener pass SEO value from the destination to the source? No. The 301 redirect passes the ranking value from the Short URL to the Long URL (Destination). It does not pass value to you, the link shortener.
What if two users are assigned the same short code?
This is called a collision. With a 6-character Base62 string, the probability is astronomically low (1 in 56 billion). If building a massive platform, you would check the DB during insertion and regenerate the key if a collision is found.
Building a scalable URL shortener is an excellent exercise in understanding distributed systems, database indexing, and caching strategies. By moving away from a simple database approach and implementing a Redis cache layer, you create a system that can handle the traffic of millions of daily clicks.
If you are implementing this for a production app, start with the Hybrid Architecture (Redis + SQL) outlined above. It offers the best balance of performance and data integrity.
Ready to build? Fork the repository below and implement the Base62 encoding engine today.