Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Bagaimana cara mendesain URL shortener seperti bit.ly?
Terima URL panjang, beri id unik dari counter atau blok id yang direservasi, lalu encode id itu dalam base62 menjadi short code. Simpan code dan URL di database dengan key berupa code, layani redirect lewat cache seperti Redis, dan catat klik secara asynchronous agar analytics tidak memperlambat redirect.
02Berapa karakter yang dibutuhkan short URL?
Base62 menghasilkan 62 pangkat n code untuk n karakter, jadi enam karakter memberi 56,8 miliar dan tujuh karakter memberi 3,5 triliun. Dengan 100 juta link baru per bulan selama lima tahun, jumlahnya mencapai 6 miliar link, yang muat di enam karakter tetapi ruangnya padat. Tujuh karakter lebih aman, terutama bila code diacak.
03URL shortener sebaiknya memakai redirect 301 atau 302?
Pakai 302 sebagai default. 301 bersifat heuristically cacheable menurut RFC 9110, sehingga browser bisa melewati server Anda pada kunjungan berikutnya, yang membuat hitungan klik kurang dan tujuan sulit diubah. Pilih 301 hanya untuk link yang benar-benar permanen dan tidak butuh analytics per klik.
04Lebih baik hash URL atau counter untuk membuat short code?
Counter yang di-encode base62 tidak pernah collision, sedangkan hash yang dipotong bisa. Dengan enam karakter dan 6 miliar link tersimpan, sekitar 10,6 persen insert hash baru akan menabrak code yang sudah dipakai dan harus di-retry. Counter atau block allocation menghindari itu, dengan biaya koordinasi sumber id.
05Bagaimana mencegah URL shortener dipakai untuk phishing?
Validasi skema dan panjang URL saat pembuatan, terapkan rate limit pembuatan link, dan cek tujuan terhadap daftar ancaman seperti Google Safe Browsing, lalu cek ulang secara berkala. Siapkan flag disabled dan hapus redirect dari cache saat flag itu diaktifkan agar takedown bekerja dalam hitungan detik. Reservasi alias yang mirip route agar tidak menimpa halaman Anda sendiri.
Untuk mendesain URL shortener seperti bit.ly, beri setiap URL panjang nilai counter unik lalu encode dalam base62, sehingga tujuh karakter cukup untuk 3,5 triliun link. Simpan code dan URL di Postgres, cache redirect yang sering diakses di Redis, pakai 302 bila butuh click analytics, dan tulis click event secara asynchronous dalam batch.
URL shortener terlihat seperti mainan: satu tabel, satu redirect. Begitu trafiknya dituliskan, ternyata ini masalah key-value yang berat di sisi baca, di mana jalur redirect harus tetap cepat sementara jalur write, analytics, dan penanganan abuse sama-sama ingin memakai database yang sama.
Ini desain dengan worked example, bukan cerita pengalaman. Saya belum pernah mengoperasikan shortener pada skala ini, jadi semua angka di bawah diturunkan dari asumsi yang disebutkan dengan hitungan yang ditampilkan, sedangkan perilaku protokol dan database berasal dari RFC 9110 serta dokumentasi PostgreSQL dan Redis. Stack-nya yang biasa saya pakai di satu VPS: Postgres, Redis, dan service Node.
Apa saja requirement-nya dan seberapa besar sistemnya?
Mulai dari apa yang dijanjikan layanan ini, karena itu menentukan setiap trade-off berikutnya. Empat requirement membawa hampir seluruh desain:
Create: ubah URL panjang menjadi short code, opsional dengan custom alias dan tanggal kedaluwarsa.
Redirect: resolve code ke URL panjang dengan latency sangat rendah, karena redirect yang lambat berarti halaman yang lambat bagi setiap pengunjung.
Analytics: hitung klik per link dari waktu ke waktu, tanpa memperlambat redirect.
Keamanan: jangan jadi cara termurah menyembunyikan link phishing, dan mampu mematikan link bermasalah dalam hitungan detik.
Sekarang hitungan kapasitasnya. Input ini adalah asumsi untuk membuat matematikanya konkret, bukan hasil pengukuran layanan nyata. Ubah asumsinya dan kesimpulannya ikut berskala linear.
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
Dua hasil membentuk semua keputusan lain. Read lebih banyak 100 banding 1 dari write, jadi jalur redirect layak diberi cache sedangkan jalur create tidak. Dan satu tahun link sebesar 600 GB pada ukuran row yang diasumsikan, masih muat di satu node Postgres, tetapi lima tahun sebesar 3 TB adalah titik di mana Anda mulai merencanakan partitioning atau sharding, bukan sekadar berharap.
Berapa panjang short code yang ideal?
Base62 memakai angka, huruf besar, dan huruf kecil: 62 simbol, jadi code sepanjang n bisa menamai 62 pangkat n link. Hitungan di bawah, memakai 6 miliar link dari blok kapasitas, memberi jawabannya.
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.
Enam karakter muat untuk 6 miliar link, tetapi hanya dengan ruang yang terisi 10,6 persen, dan saya tetap memilih tujuh. Kalau code berurutan, panjang hanya soal tampilan. Kalau code diacak atau random, ruang yang jarang justru membuat menebak code valid menjadi mahal. Base62 juga aman di path URL: RFC 3986 memasukkan huruf dan angka ke karakter unreserved, jadi tidak perlu percent-encoding. Hindari base64 karena karakter plus dan slash-nya tidak aman.
Bagaimana membuat short key yang unik?
Ada tiga strategi yang realistis. Pertanyaan yang membedakannya adalah dari mana keunikan berasal: counter, hash, atau allocator yang membagikan range.
Pendekatan
Cara keunikan dijamin
Collision
Kelemahan utama
Base62 dari counter
Sequence database memberi tiap link integer berikutnya, lalu di-encode ke base62
Tidak ada by construction
Counter adalah titik koordinasi tunggal, dan code mentah bisa dienumerasi
Hash dari URL panjang
Hash URL lalu ambil 6 sampai 7 karakter pertama
Tumbuh seiring tabel; tiap collision butuh pengecekan dan retry
Penanganan collision di jalur write, dan URL yang sama selalu menghasilkan code yang sama
Pre-generated range
Tiap instance aplikasi mereservasi satu blok id dan membagikannya dari memori
Tidak ada, karena blok tidak pernah tumpang tindih
Crash membuang sisa blok yang belum terpakai sehingga muncul gap
Opsi hash terlihat menarik karena tidak butuh koordinasi, tetapi birthday problem membuatnya diam-diam mahal. Dipotong jadi enam karakter, tersisa 56,8 miliar code, dan tingkat collision sama dengan seberapa penuh ruang itu.
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.
Saya lebih suka counter dengan block allocation: sequence atau satu row key_blocks hanya disentuh sekali per blok, bukan sekali per link, dan code dihitung di aplikasi. Berikut encoder dan decoder dalam TypeScript, memakai BigInt karena id di atas 2 pangkat 53 kehilangan presisi bila disimpan sebagai number biasa.
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
Code dari counter mentah membocorkan berapa banyak link yang sudah dibuat dan memungkinkan siapa pun menelusuri ruang code satu per satu. Mengalikan id dengan konstanta modulo 62 pangkat 7 adalah bijection murah yang menyebar id berurutan, tetapi itu obfuscation, bukan keamanan. Kalau link harus tidak bisa ditebak, pakai code random dengan unique constraint.
Seperti apa schema database-nya?
Satu tabel menjadi source of truth untuk redirect, dengan key berupa code itu sendiri sehingga lookup yang panas hanyalah satu primary-key probe. Data klik tinggal di tabel terpisah agar tidak pernah membengkakkan tabel yang dibaca redirect.
-- 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)
);
Sequence adalah sumber id paling sederhana. Dokumentasi PostgreSQL menyebut nilai sequence tidak dikembalikan ketika transaksinya di-abort dan crash dapat meninggalkan gap, yang tidak masalah untuk ruang code sebesar ini. Untuk menghindari round trip database per link, reservasi id per blok.
-- 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.
Reservasi blok cukup satu UPDATE dengan RETURNING, dan row lock membuat reservasi serentak berjalan berurutan, sehingga dua instance tidak mungkin menerima range yang sama. Harga dari keamanan itu adalah row key_blocks menjadi hot spot yang sangat kecil, itulah sebabnya blok dibuat besar.
Redirect-nya pakai 301 atau 302?
Ini pertanyaan favorit interviewer, dan jawabannya adalah pertukaran antara kecepatan dan visibilitas. RFC 9110 menyatakan 301 berarti target punya URI permanen baru dan 302 berarti target berada sementara di tempat lain, sehingga client sebaiknya tetap memakai URL asli. Perbedaan praktisnya ada di caching.
Properti
301 Moved Permanently
302 Found
Cache default
Heuristically cacheable menurut RFC 9110, jadi browser boleh memakainya ulang tanpa header eksplisit
Tidak termasuk daftar heuristically cacheable, jadi dipakai ulang hanya jika ada header freshness eksplisit
Click analytics
Klik berulang bisa tidak pernah sampai ke server Anda, sehingga hitungan kurang
Setiap klik sampai ke Anda kecuali Anda memasang max-age pendek
Mengubah tujuan
Sulit, karena salinan di cache tetap membawa target lama
Mudah, karena request berikutnya bertanya lagi ke Anda
Beban server
Terendah, karena browser melewati Anda setelah kunjungan pertama
Tertinggi, makanya Redis cache dipasang di depannya
Default saya adalah 302 dengan max-age private yang pendek, sehingga analytics bisa menghitung hampir semua klik sambil tetap menghindarkan server dari pengulangan cepat di browser yang sama. 301 hanya tepat bila link benar-benar permanen dan Anda tidak butuh hitungan per klik, misalnya mengarahkan domain lama yang sudah dipensiunkan.
# 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
301 sulit dibatalkan. Begitu browser menyimpannya di cache, Anda tidak bisa menariknya kembali: menonaktifkan link karena abuse tidak akan menghentikan client yang tidak pernah bertanya lagi, dan analytics Anda diam-diam akan kurang menghitung. Jadikan 302 sebagai default dan pilih 301 dengan sengaja.
Bagaimana cara meng-cache redirect?
Dengan read 100 kali lebih banyak dari write, pakai cache-aside di depan Postgres: cek Redis, jatuh ke database bila miss, lalu isi cache. Cache juga miss-nya, singkat saja, supaya lonjakan request untuk code yang tidak ada tidak menghajar 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
Tentukan ukuran cache dari working set, bukan total. Redis memungkinkan Anda membatasi memori dengan maxmemory dan memilih eviction policy; allkeys-lfu mempertahankan code yang sering diminta dan membuang long tail. Hit ratio lalu menentukan berapa banyak trafik yang sampai ke 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)
Hitungannya menunjukkan kenapa beberapa poin hit ratio lebih berarti dari kelihatannya: naik dari 90 ke 99 persen memangkas read database sepuluh kali lipat. Saat link dinonaktifkan atau diedit, hapus cache key-nya bersamaan, atau takedown baru berlaku setelah TTL habis.
Bagaimana mencatat klik tanpa memperlambat redirect?
Jalur redirect tidak boleh menunggu write analytics. Masukkan tiap klik ke buffer di memori, jawab redirect, lalu flush buffer dalam batch. Satu INSERT multi-row berisi ribuan klik jauh lebih murah daripada ribuan insert satu baris.
// 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),
],
);
}
Klik mentah masuk ke tabel yang di-partition, dan job berkala me-rollup-nya menjadi satu row per code per jam. Dashboard membaca tabel rollup yang kecil; partition mentah bisa di-drop per hari ketika sudah tidak diperlukan. Klausa ON CONFLICT di dokumentasi INSERT-lah yang membuat rollup aditif ini bekerja.
-- 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.
Jujur soal trade-off-nya: crash menghilangkan isi buffer, hingga satu detik klik. Untuk analytics itu kerugian yang bisa diterima, untuk billing tidak. Kalau butuh akurasi penuh, taruh durable queue di antara redirect dan database dan terima tambahan komponennya.
Bagaimana mencegah abuse?
Shortener memang menyembunyikan tujuan, dan itu disukai kampanye phishing dan malware. Penanganan abuse adalah bagian dari desain, bukan tambalan belakangan, dan lima kontrol ini menutup kasus yang umum:
Validasi saat create: izinkan hanya skema http dan https, batasi panjang URL, dan tolak URL javascript dan data.
Pasang rate limit pembuatan link per akun dan per IP, dan wajibkan akun untuk custom alias atau pembuatan massal.
Cek tujuan terhadap daftar ancaman seperti daftar Google Safe Browsing saat create, dan cek ulang berkala karena halaman yang bersih bisa berubah jadi berbahaya.
Siapkan saklar disabled_at dan hapus cache key saat mengubahnya, supaya takedown bekerja dalam hitungan detik.
Kalau Anda pernah fetch tujuan untuk membaca title-nya, blokir alamat private dan loopback dulu, atau Anda sudah membuat alat server-side request forgery.
Reservasi juga kata seperti admin, api, dan login agar custom alias tidak pernah menimpa route service Anda sendiri. Tidak satu pun dari ini istimewa; melewatkan salah satunya yang mengubah layanan kecil menjadi infrastruktur phishing milik orang lain.
URL shortener adalah key-value store yang berat di read dengan tiga pekerjaan tambahan. Turunkan angkanya lebih dulu, buat code dari counter atau range yang direservasi alih-alih hash, jadikan 302 sebagai default agar klik bisa dihitung, cache redirect-nya, dan jauhkan setiap write yang tidak esensial dari jalur request.