Short answers to what readers ask most about this topic.
01How do you design a URL shortener like bit.ly?
Accept a long URL, assign it a unique id from a counter or a reserved block of ids, and encode that id in base62 to get the short code. Store code and URL in a database keyed by the code, serve redirects through a cache such as Redis, and record clicks asynchronously so analytics never slows the redirect.
02How many characters does a short URL need?
Base62 gives 62 to the power n codes for n characters, so six characters allow 56.8 billion and seven allow 3.5 trillion. At 100 million new links a month for five years you reach 6 billion links, which fits in six characters but leaves a dense space. Seven characters are a safer choice, especially if codes are scrambled.
03Should a URL shortener use a 301 or a 302 redirect?
Use a 302 by default. A 301 is heuristically cacheable under RFC 9110, so browsers can skip your server on repeat visits, which undercounts clicks and makes the destination hard to change. Choose a 301 only for genuinely permanent links where per-click analytics do not matter.
04Is it better to hash the URL or use a counter to generate short codes?
A counter encoded in base62 never collides, while a truncated hash does. With six characters and 6 billion stored links, about 10.6 percent of new hash inserts would land on a used code and need a retry. A counter or block allocation avoids that, at the cost of coordinating the id source.
05How do you prevent a URL shortener from being used for phishing?
Validate schemes and URL length at creation, rate limit link creation, and check destinations against a threat list such as Google Safe Browsing, then re-check them periodically. Keep a disabled flag and delete the cached redirect when you set it so a takedown works within seconds. Reserve route-like aliases so they cannot shadow your own pages.
Design a URL Shortener Like bit.ly: System Design Guide
How to design a URL shortener like bit.ly: capacity arithmetic, base62 key generation, a Postgres schema, 301 versus 302, Redis caching, analytics and abuse control.
To design a URL shortener like bit.ly, give every long URL a unique counter value and encode it in base62, so seven characters cover 3.5 trillion links. Store the code and URL in Postgres, cache hot redirects in Redis, answer with a 302 when you need click analytics, and write click events asynchronously in batches.
A URL shortener looks like a toy: one table, one redirect. Then you write down the traffic and it turns out to be a textbook read-heavy key-value problem, where the redirect path must stay fast while the write path, the analytics and the abuse handling all want a share of the same database.
This is a worked design, not a war story. I have not operated a shortener at this scale, so every number below is derived from stated assumptions with the arithmetic shown, and the protocol and database behaviour comes from RFC 9110, the PostgreSQL and Redis documentation. The stack is the one I reach for on a single VPS: Postgres, Redis and a Node service.
What are the requirements and how big is the system?
Start with what the service promises, because it decides every later trade-off. Four requirements carry almost all of the design:
Create: turn a long URL into a short code, optionally with a custom alias and an expiry date.
Redirect: resolve a code to its long URL with very low latency, because a slow redirect is a slow page for every visitor.
Analytics: count clicks per link over time, without slowing the redirect.
Safety: do not become the cheapest way to hide phishing links, and be able to switch a bad link off in seconds.
Now the capacity arithmetic. These inputs are assumptions chosen to make the maths concrete, not measurements of any real service. Change them and the conclusions scale linearly.
Assumed inputs (a design exercise, not a measurement):
new short links = 100,000,000 per month
reads : writes = 100 : 1
peak vs average = 3x
retention = 5 years (60 months)
average stored row = 500 bytes (code + URL + metadata)
Writes
seconds per month = 30 x 86,400 = 2,592,000
average writes / s = 100,000,000 / 2,592,000 = 38.6
peak writes / s = 38.6 x 3 = 116
Reads (redirects)
redirects per month = 100,000,000 x 100 = 10,000,000,000
average reads / s = 10,000,000,000 / 2,592,000 = 3,858
peak reads / s = 3,858 x 3 = 11,574
redirects per day = 3,858 x 86,400 = 333,331,200 (about 333 million)
Storage
links after 1 year = 100,000,000 x 12 = 1.2 billion
links after 5 years = 100,000,000 x 60 = 6.0 billion
table size, year 1 = 1.2e9 x 500 B = 600 GB
table size, year 5 = 6.0e9 x 500 B = 3.0 TB (indexes extra, not counted)
Redirect response bandwidth (about 300 bytes of headers, assumed)
peak = 11,574 x 300 B = 3.5 MB/s
Two results shape everything else. Reads outnumber writes 100 to 1, so the redirect path deserves a cache and the create path does not. And a year of links is 600 GB at the assumed row size, which a single Postgres node can hold, but five years at 3 TB is where you would start planning partitioning or sharding rather than hoping.
How long should the short code be?
Base62 uses digits, uppercase and lowercase letters: 62 symbols, so a code of length n can name 62 to the power n links. The arithmetic below, using the 6 billion links from the capacity block, gives the answer.
Code space = 62 ^ length
62 ^ 5 = 916,132,832
62 ^ 6 = 56,800,235,584 (56.8 billion)
62 ^ 7 = 3,521,614,606,208 (3.5 trillion)
6.0 billion links over 5 years fits in 6 characters (6.0e9 / 5.68e10 = 10.6% full).
7 characters leaves 3.5e12 / 6.0e9 = about 587x headroom, which is what you want
if codes are scrambled or random, because a sparse space is hard to enumerate.
Six characters would fit 6 billion links, but only by running at 10.6 percent full, and I would still pick seven. If codes are sequential, length decides only the cosmetics. If codes are scrambled or random, a sparse space is what makes guessing a valid code expensive. Base62 is also safe in a URL path: RFC 3986 lists letters and digits among the unreserved characters, so no percent-encoding is ever needed. Avoid base64, whose plus and slash characters are not.
How do you generate unique short keys?
There are three realistic strategies. The question that separates them is where uniqueness comes from: a counter, a hash, or an allocator that hands out ranges.
Approach
How uniqueness is achieved
Collisions
Main drawback
Base62 of a counter
A database sequence gives each link the next integer, encoded in base62
None by construction
The counter is a single point of coordination, and raw codes are enumerable
Hash of the long URL
Hash the URL and keep the first 6 to 7 characters
Grow with the table; each one needs a check and a retry
Collision handling on the write path, and the same URL always maps to the same code
Pre-generated ranges
Each app instance reserves a block of ids and hands them out from memory
None, because blocks never overlap
A crash forfeits the unused part of a block, which leaves gaps
The hash option sounds attractive because it needs no coordination, but the birthday problem makes it quietly expensive. Truncating to six characters leaves 56.8 billion possible codes, and the collision rate is simply how full that space already is.
Hash a long URL, keep the first 6 base62 characters (N = 62^6 = 56,800,235,584).
Chance that a NEW insert lands on an already-used code = stored / N:
after 1.0 billion links : 1.0e9 / 5.68e10 = 1.8% of inserts collide
after 6.0 billion links : 6.0e9 / 5.68e10 = 10.6% of inserts collide
Expected colliding pairs across all 6.0e9 codes (birthday approximation n^2 / 2N):
(6.0e9)^2 / (2 x 5.68e10) = 3.6e19 / 1.136e11 = about 3.2e8 pairs
A counter-based code collides 0 times by construction.
I prefer a counter with block allocation: a sequence or a key_blocks row is touched once per block, not once per link, and the code is computed in the app. Here is the encoder and decoder in TypeScript, using BigInt because ids beyond 2 to the power 53 lose precision as a normal number.
const ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const BASE = BigInt(ALPHABET.length); // 62n
// BigInt, not number: ids past 2^53 silently lose precision as a float.
export function encodeBase62(id: bigint, minLength = 0): string {
if (id < 0n) throw new RangeError("id must be non-negative");
let out = "";
let n = id;
do {
out = ALPHABET[Number(n % BASE)] + out;
n /= BASE;
} while (n > 0n);
return out.padStart(minLength, ALPHABET[0]);
}
export function decodeBase62(code: string): bigint {
let n = 0n;
for (const ch of code) {
const digit = ALPHABET.indexOf(ch);
if (digit === -1) throw new RangeError("invalid base62 character: " + ch);
n = n * BASE + BigInt(digit);
}
return n;
}
// Optional: stop consecutive ids producing consecutive codes.
// Multiplying by K modulo 62^7 is a bijection when gcd(K, 62) = 1,
// and 62 = 2 x 31, so K must be odd and not a multiple of 31.
const SPACE = BASE ** 7n; // 3,521,614,606,208
const K = 1_000_000_007n; // odd, not divisible by 31
const K_INVERSE = modInverse(K, SPACE);
export const scramble = (id: bigint): bigint => (id * K) % SPACE;
export const unscramble = (v: bigint): bigint => (v * K_INVERSE) % SPACE;
function modInverse(a: bigint, m: bigint): bigint {
let [r0, r1, s0, s1] = [m, a % m, 0n, 1n];
while (r1 !== 0n) {
const q = r0 / r1;
[r0, r1] = [r1, r0 - q * r1];
[s0, s1] = [s1, s0 - q * s1];
}
return ((s0 % m) + m) % m;
}
// encodeBase62(0n) -> "0"
// encodeBase62(61n) -> "z"
// encodeBase62(62n) -> "10"
// encodeBase62(56_800_235_584n) -> "1000000" (62^6 needs 7 characters)
// encodeBase62(scramble(1n), 7) -> "015ftgN"
// unscramble(decodeBase62("015ftgN")) -> 1n
Raw counter codes reveal how many links you have created and let anyone walk the space one code at a time. Multiplying the id by a constant modulo 62 to the power 7 is a cheap bijection that scatters consecutive ids, but it is obfuscation, not security. If links must be unguessable, use random codes with a unique constraint instead.
What does the database schema look like?
One table is the source of truth for redirects, keyed by the code itself so the hot lookup is a single primary-key probe. Click data lives in separate tables so it can never bloat the one the redirect reads.
-- Source of unique ids. Sequences can leave gaps (aborts, crashes); that is fine here.
CREATE SEQUENCE link_id_seq START 1;
CREATE TABLE links (
code text PRIMARY KEY
CHECK (code ~ '^[0-9A-Za-z]{1,16}$'), -- custom aliases allowed
long_url text NOT NULL
CHECK (length(long_url) <= 2048 AND long_url ~* '^https?://'),
owner_id bigint, -- NULL = anonymous
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz, -- NULL = never
disabled_at timestamptz, -- takedown switch
disabled_reason text
);
CREATE INDEX links_owner_idx ON links (owner_id, created_at DESC)
WHERE owner_id IS NOT NULL;
-- Raw clicks: append-only, one partition per day so old days are dropped, not deleted.
CREATE TABLE click_events (
code text NOT NULL,
clicked_at timestamptz NOT NULL,
country char(2),
referrer_host text,
device text
) PARTITION BY RANGE (clicked_at);
CREATE TABLE click_events_2026_10_10 PARTITION OF click_events
FOR VALUES FROM ('2026-10-10') TO ('2026-10-11');
-- What dashboards actually read: one row per code per hour.
CREATE TABLE click_hourly (
code text NOT NULL,
hour timestamptz NOT NULL,
clicks bigint NOT NULL,
PRIMARY KEY (code, hour)
);
The sequence is the simplest id source. PostgreSQL documents that a sequence value is not reclaimed when its transaction aborts and that crashes can leave gaps, which is harmless for a code space this large. To avoid a database round trip per link, reserve ids in blocks instead.
-- Pre-generated ranges: each app instance reserves a block, then hands out ids from memory.
CREATE TABLE key_blocks (name text PRIMARY KEY, next_id bigint NOT NULL);
INSERT INTO key_blocks VALUES ('links', 1);
-- One round trip reserves 10,000 ids. The row lock serialises concurrent reservers.
UPDATE key_blocks
SET next_id = next_id + 10000
WHERE name = 'links'
RETURNING next_id - 10000 AS block_start; -- this instance owns block_start .. block_start + 9999
-- Block of 10,000 at the assumed 38.6 average writes/s, if ONE instance took all traffic:
-- 10,000 / 38.6 = 259 s per block, so the database is touched about every 4.3 minutes.
-- A crash forfeits the unused remainder of the block. That is a gap, not a duplicate.
The block reservation is one UPDATE with RETURNING, and the row lock serialises concurrent reservers, so two instances can never receive the same range. The cost of that safety is that the key_blocks row becomes a very small hot spot, which is why the block is large.
Should the redirect be a 301 or a 302?
This is the question interviewers like, and the answer is a trade between speed and visibility. RFC 9110 says a 301 means the target has a new permanent URI and a 302 means it resides temporarily elsewhere, so the client should keep using the original URL. The practical difference is caching.
Property
301 Moved Permanently
302 Found
Cache default
Heuristically cacheable per RFC 9110, so browsers may reuse it with no explicit headers
Not in the heuristically cacheable list, so reused only with explicit freshness headers
Click analytics
Repeat clicks may never reach your server, so counts are undercounted
Every click reaches you unless you set a short max-age
Changing the destination
Hard, because cached copies keep the old target
Easy, because the next request asks you again
Server load
Lowest, because the browser skips you after the first visit
Highest, which is why a Redis cache sits in front of it
My default is a 302 with a short private max-age, which gives analytics a chance to count nearly every click while still sparing the server from rapid repeats in the same browser. A 301 is right only when the link is truly permanent and you do not need per-click counts, such as redirecting a retired domain.
# Analytics-friendly: the browser may reuse this for 60 s, then asks you again.
HTTP/1.1 302 Found
Location: https://example.com/a/very/long/landing-page?utm_source=newsletter
Cache-Control: private, max-age=60
# Permanent: the browser may keep it indefinitely and skip your server entirely.
HTTP/1.1 301 Moved Permanently
Location: https://example.com/a/very/long/landing-page?utm_source=newsletter
Cache-Control: public, max-age=31536000
A 301 is hard to undo. Once a browser has cached it, you cannot recall it: disabling a link for abuse will not stop clients that never ask again, and your analytics will silently undercount. Default to 302 and choose 301 deliberately.
How do you cache redirects?
With 100 times more reads than writes, use cache-aside in front of Postgres: check Redis, fall back to the database on a miss, then populate the cache. Cache misses too, briefly, so a burst of requests for a code that does not exist cannot hammer the database.
const HIT_TTL_SECONDS = 24 * 60 * 60; // a day; hot codes get re-set on each miss anyway
const MISS_TTL_SECONDS = 60; // negative cache: stop typo storms reaching Postgres
export async function resolveCode(code: string): Promise<string | null> {
if (!/^[0-9A-Za-z]{1,16}$/.test(code)) return null; // reject junk before any I/O
const key = "u:" + code;
const cached = await redis.get(key);
if (cached === "") return null; // known-missing, cached as the empty string
if (cached !== null) return cached;
const { rows } = await pg.query(
"SELECT long_url FROM links " +
"WHERE code = $1 AND disabled_at IS NULL " +
"AND (expires_at IS NULL OR expires_at > now())",
[code],
);
if (rows.length === 0) {
await redis.set(key, "", "EX", MISS_TTL_SECONDS);
return null;
}
await redis.set(key, rows[0].long_url, "EX", HIT_TTL_SECONDS);
return rows[0].long_url;
}
// redis.conf for the cache instance (policy names from the Redis eviction docs):
// maxmemory 2gb
// maxmemory-policy allkeys-lfu # keep frequently used codes, evict the long tail
Size the cache from the working set, not the total. Redis lets you cap memory with maxmemory and choose an eviction policy; allkeys-lfu keeps the codes that are requested often and drops the long tail. The hit ratio then decides how much traffic reaches Postgres.
Assumed hot set: 10,000,000 codes x 200 B per entry (key + URL + overhead) = 2.0 GB
Assumed hit ratio: 90%
Peak reads reaching Postgres = 11,574 x (1 - 0.90) = about 1,157 per second
(a primary-key lookup rate you should load-test, not assume)
Each extra point of hit ratio matters more than it looks:
95% hit -> 579 / s reach the database
99% hit -> 116 / s reach the database (about the same as peak WRITES)
The arithmetic shows why a few points of hit ratio matter more than they look: going from 90 to 99 percent cuts database reads tenfold. When a link is disabled or edited, delete its cache key at the same time, or a takedown will not take effect until the TTL expires.
How do you record clicks without slowing the redirect?
The redirect path must never wait for an analytics write. Push each click into an in-memory buffer, answer the redirect, and flush the buffer in batches. One multi-row INSERT of thousands of clicks costs far less than thousands of single-row inserts.
// In the redirect handler: O(1) and non-blocking. Never await the database here.
clickBuffer.push({
code,
clickedAt: new Date(),
country: req.headers["cf-ipcountry"] ?? null,
referrerHost: hostOf(req.headers.referer),
device: classify(req.headers["user-agent"]),
});
// Every second (or at 5,000 rows, whichever first): ONE statement for the whole batch.
async function flush(rows: Click[]) {
await pg.query(
"INSERT INTO click_events (code, clicked_at, country, referrer_host, device) " +
"SELECT * FROM unnest($1::text[], $2::timestamptz[], $3::text[], $4::text[], $5::text[])",
[
rows.map((r) => r.code),
rows.map((r) => r.clickedAt),
rows.map((r) => r.country),
rows.map((r) => r.referrerHost),
rows.map((r) => r.device),
],
);
}
Raw clicks go into a partitioned table, and a periodic job rolls them up into one row per code per hour. Dashboards read the small rollup table; the raw partitions can be dropped by day when you no longer need them. The ON CONFLICT clause in the INSERT statement documentation is what makes the additive rollup work.
-- Hourly rollup: a job, not the request path. Run it once per closed hour. The
-- additive ON CONFLICT also absorbs late events, but re-running the same hour
-- would double count, so delete that hour's click_hourly rows first.
INSERT INTO click_hourly (code, hour, clicks)
SELECT code, date_trunc('hour', clicked_at), count(*)
FROM click_events
WHERE clicked_at >= '2026-10-10 09:00+00' AND clicked_at < '2026-10-10 10:00+00'
GROUP BY 1, 2
ON CONFLICT (code, hour)
DO UPDATE SET clicks = click_hourly.clicks + EXCLUDED.clicks;
-- Volume the batch absorbs (from the capacity block):
-- 333 million raw clicks per day at an assumed 60 B each = about 20 GB per day
-- Peak 11,574 clicks/s, flushed once a second = one INSERT of about 11,600 rows
-- instead of 11,574 single-row INSERTs.
Be honest about the trade: a crash loses whatever sat in the buffer, up to one second of clicks. For analytics that is an acceptable loss and for billing it would not be. If you need exactness, put a durable queue between the redirect and the database and accept the extra moving part.
How do you stop abuse?
A shortener hides the destination by design, which phishing and malware campaigns love. Abuse handling is part of the design, not a later patch, and these five controls cover the common cases:
Validate on create: allow only http and https schemes, cap the URL length, and refuse javascript and data URLs.
Rate limit creation per account and per IP, and require an account for custom aliases or bulk creation.
Check destinations against a threat list such as the Google Safe Browsing lists at creation time, and re-check periodically because a clean page can turn malicious later.
Keep a disabled_at switch and delete the cache key when you flip it, so a takedown works in seconds.
If you ever fetch the destination to read its title, block private and loopback addresses first, or you have built a server-side request forgery tool.
Also reserve words such as admin, api and login so that a custom alias can never shadow a route of your own service. None of these is exotic; skipping any one of them is what turns a small service into somebody's phishing infrastructure.
A URL shortener is a read-heavy key-value store with three jobs bolted on. Derive the numbers first, generate codes from a counter or reserved ranges instead of hashes, default to a 302 so you can count clicks, cache the redirect, and keep every non-essential write off the request path.