Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Bagaimana cara mendesain aplikasi chat real-time seperti WhatsApp?
Pakai satu WebSocket per perangkat di gateway stateless, dan simpan registry di Redis tentang gateway mana yang memegang user mana. Simpan setiap pesan di database lebih dulu dengan sequence number per percakapan, ack ke pengirim, lalu fan out lewat Redis pub/sub ke gateway. Perangkat offline melakukan sync dari cursor-nya saat reconnect dan menerima push notification selama itu.
02Bagaimana aplikasi chat menjamin urutan pesan?
Mereka tidak mempercayai jam. Server memberi sequence number per percakapan di dalam transaksi yang sama dengan penyimpanan pesan, dan client menerapkan pesan persis sesuai urutan itu. Kalau client melihat nomor yang bukan last ditambah satu, ia tahu ada gap dan meminta sync.
03Apa yang terjadi pada pesan saat penerima sedang offline?
Pesan sudah tersimpan di database, jadi tidak ada antrean offline terpisah. Ketika perangkat terhubung kembali, ia meminta semua yang lebih baru dari cursor-nya dan menerimanya per halaman. Selama offline, push notification lewat FCM atau APNs memberi tahu pengguna bahwa ada pesan masuk.
04Bisakah Redis pub/sub menjadi satu-satunya transport pesan chat?
Tidak. Redis pub/sub bersifat at-most-once, jadi pesan yang di-publish saat subscriber terputus akan hilang. Pakai sebagai bel pintu yang cepat antar server, sementara database tetap menjadi sumber kebenaran yang dipakai client untuk sync ulang.
05Bagaimana grup chat di-scale tanpa mengirim jutaan pesan?
Push langsung ke anggota untuk grup kecil, di mana delivery sama dengan jumlah anggota dikurangi satu. Di atas threshold, publish satu pemberitahuan pesan baru yang kecil per gateway dan biarkan tiap client menarik isinya dengan query sync yang sama dengan saat reconnect. Grup 100,000 anggota yang di-push per anggota pada satu pesan per detik saja sudah butuh sekitar 100,000 delivery per detik.
Desain Aplikasi Chat Seperti WhatsApp: System Design Real-Time
Cara mendesain aplikasi chat real-time seperti WhatsApp: WebSocket gateway, ack dan urutan dengan sequence number per percakapan, offline delivery, presence, dan group fan-out.
Untuk mendesain aplikasi chat real-time seperti WhatsApp, pakai satu WebSocket per perangkat di gateway stateless, rutekan pesan lewat registry user-ke-gateway di Redis dan pub/sub, simpan setiap pesan lebih dulu dengan sequence number per percakapan, ack dengan nomor itu, sync saat ada gap, dan kirim push notification bila tidak ada koneksi.
Di pekerjaan ERP dan POS, kebutuhan realtime biasanya hanya badge yang berubah tanpa refresh, dan kalau satu update terlewat, halaman berikutnya memperbaikinya. Aplikasi chat memakai pipa yang sama dengan janji yang jauh lebih berat: pesan harus tiba satu kali, berurutan, walaupun ponselnya sedang di dalam lift saat dikirim.
Saya menjalankan Postgres, Redis, dan NestJS di satu VPS, jadi saya belum pernah mengoperasikan jutaan koneksi, dan artikel ini tidak berpura-pura begitu. Seluruh sistem didesain dari asumsi yang saya sebutkan, setiap langkah hitungannya ditampilkan, dan hanya memakai komponen yang memang akan saya pilih. Fakta tentang WebSocket, Redis pub/sub, partitioning, dan masa berlaku push berasal dari spesifikasi dan dokumentasi yang ditautkan.
Apa saja yang harus dilakukan sistem chat ala WhatsApp, dan seberapa besar skalanya?
Mulai dari perilaku yang Anda janjikan, karena setiap janji punya biaya di tahap berikutnya. Empat janji ini membentuk seluruh desain:
Delivery: setiap pesan sampai ke setiap anggota minimal satu kali, dan client membuang duplikat sehingga pengguna melihatnya sekali saja.
Urutan: pesan dalam satu percakapan tampil dengan urutan yang sama di semua perangkat. Urutan antar percakapan berbeda tidak penting.
Offline: perangkat yang mati seharian mengejar ketertinggalan saat terhubung kembali, dan push notification membangunkannya ketika tidak terhubung.
Status: centang terkirim, diterima, dan dibaca, plus indikator online, semuanya cukup murah agar tidak mendominasi traffic.
Setelah itu hitung ukurannya. Semua input di bawah adalah asumsi yang saya pilih untuk latihan ini, bukan hasil pengukuran; ubah blok pertama dan sisanya ikut berubah.
Assumed inputs (a design exercise, not a measurement):
daily active users = 10,000,000
messages sent per user/day = 40
peak vs average = 3x
online at peak = 20% of DAU
stored bytes per message = 200 (body + ids + timestamps)
share of group messages = 30%, average group size 20
connections per gateway = 50,000 (assumed; load-test it)
memory per connection = 10 KB (assumed; socket + buffers + session)
Messages
sent per day = 10,000,000 x 40 = 400,000,000
average sends / s = 400,000,000 / 86,400 = 4,629.6
peak sends / s = 4,629.6 x 3 = 13,889
storage per day = 400,000,000 x 200 B = 80 GB
storage per year = 80 GB x 365 = 29.2 TB
Connections
concurrent at peak = 10,000,000 x 20% = 2,000,000
gateways needed = 2,000,000 / 50,000 = 40
connection memory, total = 2,000,000 x 10 KB = 20 GB (500 MB per gateway)
Deliveries (one send fans out to every other member)
average recipients = 0.70 x 1 + 0.30 x 19 = 6.4
deliveries per day = 400,000,000 x 6.4 = 2.56 billion
average deliveries / s = 2,560,000,000 / 86,400 = 29,629.6
peak deliveries / s = 29,629.6 x 3 = 88,889
per gateway at peak = 88,889 / 40 = 2,222 / s
Per-recipient receipt rows (16 B each) would cost
2.56 billion x 16 B = 41 GB per day -> store a cursor per member instead
Tiga angka menentukan desain. Puncak 13,889 pengiriman per detik tergolong ringan untuk database. Sebanyak 2,000,000 koneksi bersamaan adalah kendala sebenarnya, dan itu alasan gateway menjadi tier tersendiri. Lalu 88,889 delivery per detik di puncak menunjukkan bahwa fan-out, bukan tulis ke storage, yang paling sibuk: setiap pesan disimpan sekali tetapi dikirim sekitar 6.4 kali.
Bagaimana WebSocket gateway menghubungkan pengguna dan merutekan pesan?
Setiap perangkat memegang satu WebSocket ke sebuah gateway. RFC 6455 mendefinisikan kanal full-duplex yang persisten beserta control frame ping dan pong untuk mendeteksi peer yang mati. Gateway sengaja dibuat sederhana: ia mengautentikasi socket, meneruskan frame ke chat service, dan menulis frame ke socket. Ia tidak menyimpan state percakapan, jadi gateway mana pun boleh dimatikan dan client tinggal reconnect ke gateway lain.
// Gateway side. One entry per live device, refreshed while the socket is alive.
const ROUTE_TTL_S = 60; // twice the 30 s heartbeat, so one missed beat is survivable
async function onConnect(userId: string, deviceId: string, gwId: string) {
// Hash per user: fields are device ids, values are the gateway that holds the socket.
await redis.hset("route:" + userId, deviceId, gwId);
await redis.expire("route:" + userId, ROUTE_TTL_S);
}
async function onDisconnect(userId: string, deviceId: string) {
await redis.hdel("route:" + userId, deviceId);
}
// A stale field (gateway crashed, TTL not yet expired) only means one publish goes
// to a gateway that no longer has the socket. It drops the frame; the message is
// already in the database, so nothing is lost.
Satu hal yang dibutuhkan sistem adalah peta dari user ke gateway yang memegang socket-nya. Cukup satu hash Redis per user dengan TTL pendek yang diperpanjang oleh heartbeat, karena peta ini hanya petunjuk. Kebenaran ada di database.
Pendekatan routing
Pesan pub/sub saat puncak (hasil hitung)
Apa yang bermasalah
Broadcast setiap pesan ke semua gateway
13,889 per detik x 40 gateway = 555,556 diterima per detik
Tiap gateway membuang sebagian besar yang diterimanya; biaya naik seiring jumlah gateway.
Satu channel per user
Paling banyak 88,889 per detik, tetapi 2,000,000 channel
Churn subscribe dan unsubscribe di setiap connect dan disconnect.
Route registry plus satu channel per gateway
Paling banyak 88,889 per detik, biasanya jauh lebih sedikit setelah di-batch per gateway
Butuh registry, dan route yang basi harus ditoleransi. Ini pilihan awal saya.
Pengirim mencari semua penerima dalam satu round trip pipeline, mengelompokkannya per gateway, lalu publish sekali per gateway. Grup 20 anggota yang tersebar di 3 gateway hanya butuh 3 publish, bukan 19. Tabel di atas menjelaskan alasannya: broadcast menghasilkan 555,556 penerimaan per detik, sekitar 6 kali batas 88,889 pada desain yang tertarget.
// Every gateway subscribes to exactly one channel: its own id.
const sub = redis.duplicate();
await sub.subscribe("gw:" + MY_GATEWAY_ID);
sub.on("message", (_channel, raw) => {
const { userIds, frame } = JSON.parse(raw);
for (const uid of userIds) {
for (const socket of localSockets.get(uid) ?? []) socket.send(JSON.stringify(frame));
}
});
// Sender side: group recipients by gateway, then publish ONCE PER GATEWAY, not per user.
async function deliver(frame: MsgFrame, memberIds: string[]) {
const pipe = redis.pipeline();
memberIds.forEach((uid) => pipe.hgetall("route:" + uid));
const routes = await pipe.exec(); // one round trip for the whole group
const byGateway = new Map<string, string[]>();
const offline: string[] = [];
memberIds.forEach((uid, i) => {
const devices = Object.values((routes[i][1] as Record<string, string>) ?? {});
if (devices.length === 0) offline.push(uid);
for (const gw of new Set(devices)) {
byGateway.set(gw, [...(byGateway.get(gw) ?? []), uid]);
}
});
for (const [gw, userIds] of byGateway) {
await redis.publish("gw:" + gw, JSON.stringify({ userIds, frame }));
}
return offline; // these users get a push notification instead
}
Scale out setelah itu hampir murni hitungan. Dengan 50,000 koneksi per gateway, Anda butuh 40 gateway untuk 2,000,000 socket, dan masing-masing menangani sekitar 2,222 delivery per detik. Untuk detail wiring NestJS dan Redis, artikel saya tentang WebSocket dengan NestJS dan tentang scaling WebSocket dengan Redis pub/sub membahasnya; artikel ini membahas apa yang ada di sekelilingnya.
Redis pub/sub bersifat at-most-once: pesan yang di-publish saat subscriber terputus hilang selamanya. Ini bisa diterima di sini hanya karena pub/sub berfungsi sebagai bel pintu. Pesannya sudah di-commit di database, dan client melakukan sync ulang dari cursor-nya. Jangan pernah menjadikan pub/sub satu-satunya salinan pesan chat.
Bagaimana pesan mengalir dari pengirim ke penerima, dan apa yang di-ack?
Beri setiap pesan sebuah id buatan client, disebut mid di bawah. Client terus mengulang pengiriman dengan mid yang sama sampai melihat ack, dan server memakai mid itu agar pengulangan tidak berbahaya. Bentuk frame-nya seperti ini:
client -> gateway {"t":"send","cid":"c_91","mid":"7f3a-41","body":"on my way"}
gateway -> client {"t":"ack","mid":"7f3a-41","cid":"c_91","seq":1042} // stored: first tick
gateway -> peers {"t":"msg","cid":"c_91","seq":1042,"from":"u_7","body":"on my way"}
peer -> gateway {"t":"recv","cid":"c_91","upTo":1042} // delivered: second tick
peer -> gateway {"t":"read","cid":"c_91","upTo":1042} // read
Ack berarti tersimpan, bukan terkirim. Itu centang pertama. Karena itu handler bekerja dengan urutan tetap: dedupe, alokasi sequence number, insert, commit, ack ke pengirim, dan baru setelah itu fan out.
async function handleSend(conn: Conn, f: SendFrame) {
const { seq } = await pg.tx(async (tx) => {
// Retry of a message we already stored? Return the SAME seq, do not insert again.
const dup = await tx.oneOrNone(
"SELECT seq FROM messages WHERE conversation_id = $1 AND sender_id = $2 AND client_msg_id = $3",
[f.cid, conn.userId, f.mid],
);
if (dup) return dup;
// The row lock serialises senders in THIS conversation only. If the INSERT below
// fails, the transaction rolls back and the counter goes back with it: no gap.
const { seq } = await tx.one(
"UPDATE conversations SET last_seq = last_seq + 1 WHERE id = $1 RETURNING last_seq AS seq",
[f.cid],
);
await tx.none(
"INSERT INTO messages (conversation_id, seq, sender_id, client_msg_id, body) VALUES ($1, $2, $3, $4, $5)",
[f.cid, seq, conn.userId, f.mid, f.body],
);
return { seq };
});
conn.send(JSON.stringify({ t: "ack", mid: f.mid, cid: f.cid, seq })); // after the commit
const offline = await deliver({ t: "msg", cid: f.cid, seq, from: conn.userId, body: f.body }, await memberIds(f.cid));
await pushFallback(offline, f.cid, seq);
}
Ack setelah commit adalah aturan yang paling penting. Kalau Anda ack lebih dulu lalu crash sebelum insert, pengirim mengira ada pesan yang tidak akan pernah ada. Kalau Anda commit lalu crash sebelum ack, pengirim mengulang dengan mid yang sama, query dedupe menemukan baris yang tersimpan, dan seq yang sama dikembalikan. At-least-once di jaringan ditambah penyimpanan idempoten menghasilkan exactly-once bagi pembaca.
Bagaimana menjaga urutan pesan, dan seperti apa skema penyimpanannya?
Urutkan dengan nomor yang diberikan server per percakapan, jangan pernah dengan timestamp. Jam ponsel bisa melenceng dan pesan bisa tertunda di jalan, sehingga timestamp mengurutkan pesan berdasarkan siapa yang jamnya paling buruk. Counter per percakapan itu sederhana, dan juga memberi client cara murah mendeteksi gap: kalau seq berikutnya bukan last ditambah satu, ada yang terlewat.
-- Wrong: order by the sender's clock. Two phones 3 seconds apart disagree on what came first.
SELECT body FROM messages WHERE conversation_id = $1 ORDER BY client_sent_at;
-- Right: order by the number the server assigned inside the transaction.
SELECT body FROM messages WHERE conversation_id = $1 ORDER BY seq;
// Client rule: apply messages strictly in seq order per conversation.
function onMsg(m: Msg) {
const last = lastSeq.get(m.cid) ?? 0;
if (m.seq <= last) return; // duplicate, ignore
if (m.seq > last + 1) { requestSync(m.cid, last); return; } // gap: fetch last+1 onward
render(m);
lastSeq.set(m.cid, m.seq);
}
Counter global akan melewatkan 13,889 penulisan per detik melalui satu baris. Baris counter per percakapan hanya menserialkan pengirim di percakapan yang sama. Skema di bawah menaruh counter di conversation, menjadikan composite primary key sebagai jalur akses, dan menyimpan status delivery sebagai cursor per anggota.
CREATE TABLE conversations (
id bigint PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('direct', 'group')),
last_seq bigint NOT NULL DEFAULT 0 -- the per-conversation counter
);
-- Delivery state is a cursor per member, not a row per message per recipient.
CREATE TABLE members (
conversation_id bigint NOT NULL REFERENCES conversations (id),
user_id bigint NOT NULL,
last_delivered_seq bigint NOT NULL DEFAULT 0,
last_read_seq bigint NOT NULL DEFAULT 0,
PRIMARY KEY (conversation_id, user_id)
);
CREATE INDEX members_user_idx ON members (user_id);
CREATE TABLE messages (
conversation_id bigint NOT NULL,
seq bigint NOT NULL,
sender_id bigint NOT NULL,
client_msg_id uuid NOT NULL, -- idempotency key from the client
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (conversation_id, seq),
UNIQUE (conversation_id, sender_id, client_msg_id)
) PARTITION BY HASH (conversation_id);
CREATE TABLE messages_p0 PARTITION OF messages
FOR VALUES WITH (MODULUS 16, REMAINDER 0); -- repeat for remainders 1 to 15
-- 29.2 TB per year / 16 partitions = about 1.8 TB each. Partitioning keeps indexes
-- small; it does not make one server hold 29.2 TB. Spread partitions across hosts.
Baris receipt per penerima akan menjadi 2.56 miliar baris atau 41 GB per hari menurut hitungan di atas. Cursor per anggota hanya butuh satu baris per keanggotaan dan ditimpa di tempat. Partitioning dengan hash conversation_id mengikuti dokumentasi declarative partitioning Postgres, dan menjaga setiap percakapan dalam satu partition sehingga range scan pada seq tetap lokal.
Percakapan yang sangat ramai dibatasi oleh satu row lock. Kalau suatu grup mengirim lebih cepat dari satu transaksi per round trip, pindahkan counter ke Redis dengan INCR dan terima bahwa crash bisa meninggalkan gap. Client sudah menganggap gap sebagai alasan untuk sync, jadi desain ini menoleransi trade-off tersebut.
Bagaimana mengirim pesan ke pengguna offline, dan kapan fallback ke push?
Offline delivery bukan antrean terpisah. Pesannya sudah ada di database, jadi mengejar ketertinggalan hanyalah sebuah query. Saat connect, client mengirim hello, lalu server membandingkan cursor tiap keanggotaan dengan counter percakapan dan hanya mengembalikan yang lebih baru, dalam halaman terbatas.
-- On reconnect the client sends only: {"t":"hello"}. The server owns the cursors.
SELECT m.conversation_id, m.last_delivered_seq, c.last_seq
FROM members m
JOIN conversations c ON c.id = m.conversation_id
WHERE m.user_id = $1
AND c.last_seq > m.last_delivered_seq; -- only conversations with something new
-- Then, per conversation, a bounded page (primary-key range scan, already ordered):
SELECT seq, sender_id, body, created_at
FROM messages
WHERE conversation_id = $1 AND seq > $2
ORDER BY seq
LIMIT 200;
Ketika langkah deliver tidak menemukan route untuk seorang penerima, ia mengirim push notification. Pakai payload hanya untuk memberi tahu ada yang baru, dan taruh id percakapan serta seq di field data. FCM memungkinkan masa berlaku pesan hingga empat minggu, dan collapse key membuat serangkaian pesan menghasilkan satu notifikasi tertunda, bukan lima puluh. Web Push punya ide yang sama: RFC 8030 mewajibkan header TTL dan membiarkan push service menahan pesan sampai perangkat kembali.
Karena collapse key membuang semua notifikasi kecuali yang terakhir, notifikasi tidak boleh membawa pesannya sendiri. Ia hanya menyuruh aplikasi membuka socket dan sync. Itu juga menjaga isi pesan tidak lewat penyedia push, yang merupakan keuntungan privasi dan bukan sekadar kemudahan desain.
Bagaimana group message fan-out bekerja?
Fan-out adalah bagian yang membuat sistem chat mahal. Anda memilih kapan pekerjaan dilakukan: saat write, mendorong ke setiap anggota begitu pesan masuk, atau saat read, membiarkan anggota menarik ketika mereka membuka chat. Kebanyakan sistem berakhir dengan hybrid.
Strategi
Bentuk biaya
Dipakai untuk
Fan-out on write (push ke setiap anggota)
Delivery per pesan sama dengan jumlah anggota dikurangi satu; latensi terendah
Chat langsung dan grup hingga beberapa ratus anggota
Fan-out on read (anggota menarik)
Satu write per pesan; biaya dibayar tiap pembaca saat membuka
Grup sangat besar dan channel bergaya broadcast
Hybrid (push pemberitahuan, tarik isinya)
Satu publish kecil per gateway plus satu query sync per pembaca
Semua yang di atas threshold push
Threshold bukan soal selera, ia muncul dari angka kapasitas. Batas 500 anggota di bawah adalah titik awal asumsi yang perlu disetel dengan load test.
Strategy by group size (push threshold of 500 members is an assumed starting point):
group of 20, 1 message/s -> 19 deliveries/s push each fine
group of 500, 1 message/s -> 499 deliveries/s push each fine
group of 100,000, 1 message/s -> 99,999 deliveries/s push each IMPOSSIBLE:
more than the whole system's assumed peak of 88,889 deliveries/s
Large group: publish ONE tiny "new seq" notice per gateway (40 publishes, not 99,999),
and let each client pull the page it needs with the same sync query as a reconnect.
Query sync yang sama yang melayani reconnect juga melayani grup besar, jadi hybrid tidak menambah jalur kode baru. Urutan tidak terpengaruh karena seq tetap berasal dari satu counter percakapan.
Bagaimana presence (online dan terakhir dilihat) di-scale?
Presence tampak gratis padahal tidak. Kalau setiap koneksi memperbarui sebuah key tiap 30 detik, presence menjadi jalur tulis tersibuk di sistem:
Heartbeat every 30 s from every connection (assumed), refreshing the route key's TTL:
2,000,000 connections / 30 s = 66,667 refreshes per second
peak message sends = 13,889 per second
66,667 / 13,889 = 4.8x as many presence writes as message sends
Batch per gateway (50,000 connections, one pipeline flushed each second):
50,000 / 30 = 1,667 commands in ONE round trip, 40 gateways -> 40 round trips/s
Dua perubahan menyelesaikannya. Gateway mem-batch pembaruan ke satu pipeline per detik, sehingga Redis melihat 40 round trip per detik, bukan 66,667. Dan pembaca menarik presence untuk chat yang sedang terbuka, bukan setiap kontak diberi tahu tiap perubahan.
// Gateway: remember who beat since the last flush, send one pipeline per second.
const beaten = new Set<string>();
socket.on("pong", () => beaten.add(socket.userId)); // RFC 6455 ping/pong control frames
setInterval(async () => {
const pipe = redis.pipeline();
for (const uid of beaten) pipe.expire("route:" + uid, 60);
beaten.clear();
await pipe.exec();
}, 1000);
// Reader side: no push to every contact. Ask only for the chat the user has open.
// "online" = the route key exists. "last seen" = a column written on disconnect.
const online = (await redis.exists("route:" + peerId)) === 1;
Pakai ulang route key untuk presence: online berarti key-nya ada. Tulis terakhir dilihat ke sebuah kolom saat socket tertutup, dan terima bahwa crash menampilkan terakhir dilihat yang basi sampai TTL habis. Keduanya perkiraan yang jujur, dan pengguna tidak bisa membedakannya.
Apa yang harus diputuskan sebelum membangun sistem chat?
Sebelum menulis kode, Anda harus bisa menjawab tiap pertanyaan ini dalam satu kalimat. Kalau tidak bisa, itulah bagian desain yang masih terbuka.
Apa kontrak ack-nya: tersimpan, terkirim, atau dibaca, dan apakah pengirim mengulang dengan client id yang sama?
Dari mana sequence number per percakapan berasal, dan apa yang terjadi padanya saat rollback?
Apakah database satu-satunya salinan pesan, dengan pub/sub diperlakukan sebagai bel pintu?
Berapa threshold push untuk ukuran grup, dan apa yang dilakukan client saat ada gap?
Bagaimana route yang basi ditangani ketika gateway crash?
Berapa biaya presence per detik pada jumlah koneksi puncak Anda?
Tidak satu pun membutuhkan teknologi eksotis. Satu primary Postgres dan satu instance Redis sudah cukup untuk produk kecil; desain di atas adalah tempat Anda bertumbuh, dan angkanya memberi tahu kapan tiap bagian mulai penting.
Sistem chat adalah masalah penyimpanan yang dibungkus masalah routing. Simpan dulu dan beri nomor pesan per percakapan, jadikan database sebagai sumber kebenaran, dan perlakukan setiap kanal real-time sebagai petunjuk yang bisa hilang. Dengan begitu, gateway, push, dan presence menjadi bagian yang bisa Anda scale satu per satu.