Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Bagaimana cara mendesain sistem notifikasi yang scalable?
Pisahkan event dari pengiriman. Kode bisnis memancarkan event, ingester menulis baris notifikasi yang terdedup, dan satu worker per channel (push, email, SMS) mengirimnya setelah mengecek preferensi. Tambahkan retry dengan backoff, dead-letter queue, rate limit per provider, dan pelacakan status lewat webhook.
02Mengapa push, email, dan SMS sebaiknya punya queue terpisah?
Setiap channel punya provider, rate limit, dan pola kegagalan sendiri. Dengan satu queue bersama, provider SMS yang terkena throttle bisa menahan pesan push yang sudah siap dikirim. Queue terpisah memungkinkan Anda mengatur concurrency dan retry per channel dan melihat backlog masing-masing.
03Bagaimana mencegah notifikasi terkirim ganda?
Bangun dedup key dari niat pengiriman, misalnya id event, user, dan channel, lalu pasang unique constraint padanya. Lakukan insert dengan ON CONFLICT DO NOTHING dan kirim hanya jika ada baris yang ter-insert. Jangan memakai id acak per percobaan, karena setiap retry akan tampak seperti pesan baru.
04Bagaimana sistem notifikasi menangani retry dan kegagalan?
Klasifikasikan hasilnya dulu. Error permanen seperti 400 atau hard bounce langsung ke dead-letter queue, sedangkan error sementara seperti 429 atau 500 di-retry dengan exponential backoff dan jitter. Patuhi Retry-After bila provider mengirimnya dan tetapkan usia maksimum, misalnya satu jam.
Simpan message id dari provider saat mengirim, lalu perbarui baris dari webhook provider. SES mempublikasikan event seperti Delivery dan Bounce, dan Twilio mengirim POST untuk setiap perubahan status ke URL callback. Callback bisa datang tidak berurutan, jadi izinkan status hanya bergerak maju.
Cara Mendesain Sistem Notifikasi Scalable (Push, Email, SMS)
Sistem notifikasi terdiri dari event ingester, satu queue per channel, pengecekan preferensi, constraint dedup, retry ke dead-letter queue, dan tracking delivery lewat webhook.
Rancang sistem notifikasi yang scalable dengan mengubah setiap event bisnis menjadi satu baris database yang terdedup, membaginya ke queue terpisah per channel (push, email, SMS), mengecek preferensi dan suppression sebelum kirim, me-retry kegagalan sementara dengan backoff ke dead-letter queue, membatasi laju tiap provider, dan memperbarui status dari webhook provider.
Fitur notifikasi pertama di kebanyakan sistem hanyalah satu fungsi: setelah order dibayar, panggil API email. Ini jalan sampai suatu hari provider lambat, request timeout, job di-retry, dan pelanggan menerima struk yang sama tiga kali.
Post ini membahas arsitektur yang berada di antara event bisnis dan ponsel yang bergetar. Stack-nya tetap yang saya pakai sehari-hari, Postgres dan Redis di server sederhana, dan semua angka di bawah adalah hitungan dari asumsi yang disebutkan atau diambil dari dokumen provider di bagian akhir, bukan benchmark. Mekanisme queue dan webhook keluar sudah punya post sendiri di blog ini, jadi di sini fokusnya pada hal yang khas untuk notifikasi.
Apa sebenarnya tugas sistem notifikasi?
Tugasnya mengubah sebuah fakta (order 8841 sudah dibayar) menjadi nol atau lebih pesan di channel yang tepat, masing-masing sekali, dalam bahasa pengguna, dan menghormati pilihan mereka. Kode bisnis cukup memancarkan event. Semua urusan channel, template, dan provider ada di balik sebuah boundary, sehingga menambah WhatsApp nanti hanya mengubah satu worker, bukan empat puluh tempat pemanggilan. Dua bentuk payload di bawah adalah kontraknya.
type Channel = "push" | "email" | "sms";
type Priority = "critical" | "bulk"; // OTP and receipts must never queue behind a campaign
// What the business code emits. It knows nothing about channels or providers.
interface NotificationEvent {
eventId: string; // stable id from the source system, e.g. "order-8841-paid"
type: "order.paid" | "otp.requested" | "invoice.overdue";
userId: string;
occurredAt: string; // ISO 8601
data: Record<string, string | number>;
}
// What a channel worker consumes. One job per (notification, channel).
interface SendJob {
notificationId: string; // primary key of the notifications row
dedupKey: string; // eventId + ":" + userId + ":" + channel
channel: Channel;
priority: Priority;
userId: string;
templateKey: string; // "order.paid"
templateVersion: number; // pinned at enqueue time, so a retry renders the same copy
locale: "en" | "id";
vars: Record<string, string | number>;
attempt: number; // 0 on first try
notBefore?: string; // set by the quiet-hours rule or by a backoff
}
Dua keputusan tertanam di bentuk itu. Pertama, template dirujuk lewat key dan version, bukan dirender saat enqueue, sehingga retry satu jam kemudian menghasilkan teks yang sama, dan error rendering (SES melaporkannya sebagai event Rendering Failure) tertangkap di worker, bukan meracuni producer. Kedua, baris notifikasi ditulis dalam transaksi database yang sama dengan perubahan bisnisnya, sehingga event yang sudah commit tidak mungkin hilang di antara commit dan queue.
Mengapa memakai satu queue per channel, bukan satu queue bersama?
Karena ketiga channel gagal dengan cara berbeda, pada kecepatan berbeda, di bawah limit berbeda. Satu queue bersama membuat provider SMS yang terkena rate limit menahan pesan push yang sebenarnya siap dikirim. Queue terpisah memberi tiap channel concurrency, kebijakan retry, dan backlog sendiri yang mudah dibaca, dan pemisahan prioritas di dalamnya menjaga kode OTP tetap di depan kampanye.
Channel
Dari mana status datang
Bentuk kegagalan
Aturan retry yang layak ditulis
Push (FCM)
Response dari request kirim
400, 401, 403, 404 bersifat permanen; 429 dan 500 bersifat sementara
Abort pada 4xx; patuhi Retry-After pada 429, default 60 detik; exponential backoff pada 500; tunggu minimal 10 detik
Email (SES)
Event yang dipublikasikan ke SNS: Send, Delivery, Bounce, Complaint, Reject, DeliveryDelay
Bounce Permanent berarti alamat mati; Complaint berarti pengguna menandai Anda sebagai spam
Jangan pernah retry bounce Permanent; langsung masukkan alamat ke suppressions
SMS (Twilio)
POST StatusCallback untuk setiap perubahan status
Status berakhir di delivered, undelivered, atau failed, disertai ErrorCode
Callback bisa datang tidak berurutan, jadi status tidak boleh mundur
Tabel ini alasan kode retry di bagian berikutnya mengembalikan outcome bertipe, bukan melempar exception. Worker yang tidak bisa membedakan kegagalan permanen dari sementara akan me-retry alamat mati delapan kali dan membuat provider kehilangan kepercayaan pada reputasi pengirim Anda.
Bagaimana menangani preferensi dan opt-out pengguna?
Simpan preferensi dan suppression di dua tabel terpisah, karena penulisnya berbeda. Preferensi adalah pilihan pengguna, per kategori dan per channel. Suppression adalah kabar dari provider, misalnya hard bounce, complaint, atau balasan STOP, dan ia mengalahkan semua preferensi karena tetap mengirim merusak reputasi Anda. Schema di bawah membuat baris preferensi yang tidak ada berarti default, sehingga kategori baru tidak perlu backfill.
CREATE TYPE channel AS ENUM ('push', 'email', 'sms');
-- One row per (user, category, channel). A missing row means "use the default",
-- so a new category never needs a backfill across every user.
CREATE TABLE notification_preferences (
user_id uuid NOT NULL,
category text NOT NULL, -- 'transactional', 'security', 'marketing'
channel channel NOT NULL,
enabled boolean NOT NULL DEFAULT true,
quiet_start time, -- local time, NULL = no quiet hours
quiet_end time,
timezone text NOT NULL DEFAULT 'Asia/Jakarta',
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, category, channel)
);
-- Written by webhooks, NOT by the user. A hard bounce or an unsubscribe lands here
-- and overrides any preference row, because the provider has already told you no.
CREATE TABLE channel_suppressions (
channel channel NOT NULL,
address text NOT NULL, -- email, E.164 phone number or push token
reason text NOT NULL, -- 'hard_bounce', 'complaint', 'unsubscribe', 'stop'
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (channel, address)
);
Untuk email, buat unsubscribe cukup satu request. RFC 8058 mendefinisikan header List-Unsubscribe-Post dengan nilai List-Unsubscribe=One-Click agar mail client bisa berhenti berlangganan dengan satu POST, dan handler Anda sebaiknya langsung menulis ke channel_suppressions. Cek preferensi saat mengirim, bukan saat enqueue, karena pengguna yang opt-out ketika job menunggu di queue berharap pengiriman berhenti. Kategori security dan transactional sebaiknya tidak bisa dimatikan, dan UI harus menyatakannya.
Bagaimana mencegah notifikasi yang sama terkirim dua kali?
Beri setiap pesan yang dimaksudkan sebuah dedup key yang deterministik, lalu biarkan unique constraint database menentukan siapa yang pertama. Key dibangun dari hal yang mengidentifikasi niat, bukan percobaan: id event sumber, user, dan channel, misalnya order-8841-paid:user-17:email. UUID acak per percobaan, kesalahan yang paling umum, membuat setiap retry tampak seperti pesan baru.
CREATE TABLE notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
dedup_key text NOT NULL UNIQUE, -- the whole mechanism is this constraint
user_id uuid NOT NULL,
channel channel NOT NULL,
status text NOT NULL DEFAULT 'queued',
provider_id text, -- the provider's message id, for webhooks
created_at timestamptz NOT NULL DEFAULT now()
);
-- Wrong: SELECT to check, then INSERT. Two consumers can both see "not there".
-- Right: let the unique index decide, atomically.
INSERT INTO notifications (dedup_key, user_id, channel)
VALUES ($1, $2, $3)
ON CONFLICT (dedup_key) DO NOTHING
RETURNING id;
-- 1 row back -> first time we have seen this event: enqueue the job.
-- 0 rows back -> a replay or a duplicate delivery: stop, send nothing.
INSERT dengan ON CONFLICT DO NOTHING bersifat atomic: dokumentasi PostgreSQL menyebut RETURNING hanya mengembalikan baris yang benar-benar di-insert, jadi nol baris berarti sudah ada yang memiliki key itu. Ini mencakup producer yang me-replay event dan queue yang mengirim job dua kali. Ini tidak mencakup celah setelah provider menerima pesan tetapi sebelum Anda mencatatnya, itulah sebabnya peringatan berikut penting.
Exactly-once tidak tersedia ketika panggilan provider dan database Anda adalah dua sistem. Jika proses mati setelah provider menerima pesan dan sebelum id-nya disimpan, retry akan mengirim lagi. Jika provider menerima idempotency key, teruskan dedup key Anda; jika tidak, terima sesekali duplikat dan pilih itu daripada OTP yang hilang.
Bagaimana retry dan dead-letter queue seharusnya bekerja?
Klasifikasikan outcome dulu, baru putuskan. Error permanen langsung ke dead-letter queue, karena me-retry 400 hanya mengulang 400. Error sementara di-retry dengan exponential backoff dan jitter. Panduan FCM sendiri: tunggu minimal 10 detik, patuhi Retry-After pada 429 dengan default 60 detik, pakai exponential backoff pada 500, dan berhenti me-retry setelah 60 menit.
const BASE_MS = 10_000; // never retry sooner than 10 s
const MAX_ATTEMPTS = 8; // 10+20+40+80+160+320+640+1280 = 2,550 s of ceilings
const DEADLINE_MS = 60 * 60_000; // give up after an hour, the provider's own advice
// Full jitter: wait a random time between 0 and the exponential ceiling, so a
// thousand jobs that failed together do not all come back together.
const backoffMs = (attempt: number) =>
Math.random() * BASE_MS * 2 ** attempt;
type Outcome =
| { kind: "sent"; providerId: string }
| { kind: "retry"; afterMs?: number } // 429 with Retry-After, 5xx, timeout
| { kind: "dead"; reason: string }; // 400, 401, 403, 404: retrying cannot help
async function handle(job: SendJob, firstSeenAt: number) {
const out = await sendViaProvider(job);
if (out.kind === "sent") return markSent(job, out.providerId);
const exhausted =
out.kind === "dead" ||
job.attempt + 1 >= MAX_ATTEMPTS ||
Date.now() - firstSeenAt > DEADLINE_MS;
if (exhausted) return moveToDeadLetter(job, out); // keep the payload, add the reason
// The provider's Retry-After beats our own schedule when it sends one.
const delay = out.afterMs ?? backoffMs(job.attempt);
return requeue({ ...job, attempt: job.attempt + 1 }, delay);
}
Contoh hitungan dengan base 10 detik dan penggandaan: batas atasnya 10, 20, 40, 80, 160, 320, 640, dan 1.280 detik, totalnya 2.550 detik, sekitar 42,5 menit. Percobaan kesembilan menambah 2.560 detik dan berakhir di 5.110, melewati satu jam 3.600 detik, jadi delapan percobaan adalah batas yang wajar. Dead-letter queue bukan kuburan: simpan payload, error terakhir, dan jumlah percobaan, beri alert pada kedalamannya, dan sediakan replay satu klik untuk hari ketika provider sedang bermasalah.
Bagaimana menghormati rate limit provider dan melakukan failover?
Pasang token bucket di depan setiap panggilan provider, ukurannya sesuai limit akun Anda, dan buat worker menunggu, bukan gagal, saat bucket kosong. FCM, misalnya, men-throttle dengan 429 ketika quota bucket habis, dan panduannya adalah menaikkan trafik selama minimal jendela 60 detik, bukan langsung penuh. Failover adalah provider kedua di belakang circuit breaker, dipakai hanya untuk masalah di sisi provider seperti timeout dan 5xx, tidak pernah untuk penerima yang salah.
// A token bucket per provider. In-memory is enough on ONE worker process;
// with several workers the bucket must live in Redis or the limit multiplies.
class TokenBucket {
private tokens: number;
private last = Date.now();
constructor(private ratePerSec: number, private burst: number) {
this.tokens = burst;
}
tryTake(): boolean {
const now = Date.now();
this.tokens = Math.min(
this.burst,
this.tokens + ((now - this.last) / 1000) * this.ratePerSec,
);
this.last = now;
if (this.tokens < 1) return false;
this.tokens -= 1;
return true;
}
}
const smsPrimary = new TokenBucket(20, 20); // 20/s is an example, read your own account limit
async function sendSms(job: SendJob): Promise<Outcome> {
if (!smsPrimary.tryTake()) return { kind: "retry", afterMs: 50 }; // wait for a token
if (breaker.isOpen("sms-primary")) return sendViaSecondary(job); // failover
const out = await callPrimary(job);
// Fail over on PROVIDER trouble only. A 400 for a bad number fails identically
// on the secondary, and you would pay twice to learn the same thing.
if (out.kind === "retry") breaker.recordFailure("sms-primary");
return out;
}
Hitungan menunjukkan mengapa jalur prioritas penting. Anggap akun SMS mengizinkan 20 pesan per detik, asumsi untuk ilustrasi. Kampanye 12.000 pesan butuh 12.000 dibagi 20, yaitu 600 detik, sepuluh menit, dan OTP yang antre di belakangnya menunggu selama itu. Dengan jalur critical terpisah yang dikuras lebih dulu, OTP yang sama hanya menunggu beberapa token. Failover punya biaya sendiri: timeout itu ambigu, karena provider pertama mungkin sudah mengirim, sehingga pengiriman yang di-failover bisa menjadi duplikat.
Jalankan bucket di Redis begitu worker Anda lebih dari satu proses. Bucket in-memory per worker diam-diam melipatgandakan limit Anda sebanyak jumlah worker, dan tanda pertamanya adalah gelombang 429 dari provider.
Bagaimana melacak delivery lewat webhook provider?
Panggilan kirim hanya memberi tahu bahwa provider menerima pesan. Delivery, bounce, dan kegagalan datang belakangan sebagai webhook: SES mempublikasikan Send, Delivery, Bounce, Complaint, dan lainnya ke SNS, dan Twilio mengirim POST untuk setiap perubahan status ke URL StatusCallback Anda. Cari notifikasi lewat message id dari provider, itu sebabnya tabel notifications menyimpannya. Twilio menyatakan dengan jelas bahwa callback tidak dijamin tiba sesuai urutan pengirimannya.
-- Rank the statuses, then only ever move forward. The webhook for "sent" can
-- arrive AFTER the one for "delivered"; without the guard it would overwrite it.
UPDATE notifications
SET status = $2
WHERE provider_id = $1
AND (CASE status
WHEN 'queued' THEN 1
WHEN 'sent' THEN 2
WHEN 'delivered' THEN 3
WHEN 'undelivered' THEN 3
WHEN 'failed' THEN 3
ELSE 0
END)
< (CASE $2
WHEN 'sent' THEN 2
WHEN 'delivered' THEN 3
WHEN 'undelivered' THEN 3
WHEN 'failed' THEN 3
ELSE 0
END);
-- Terminal bad news also feeds the suppression table, so the next send is skipped:
-- email Bounce with bounceType Permanent -> reason 'hard_bounce'
-- email Complaint -> reason 'complaint'
Karena itu update harus hanya maju. Beri peringkat pada status dan tulis status baru hanya jika peringkatnya lebih tinggi, seperti pada query di atas, kalau tidak sent yang terlambat menimpa delivered dan dashboard Anda berbohong. Buat handler webhook idempotent juga, karena provider me-retry callback mereka, dan biarkan kabar buruk yang final menulis ke tabel suppression agar pengiriman berikutnya dilewati tanpa ada yang perlu memeriksa.
Apa checklist sebelum sistem notifikasi dirilis?
Pakai daftar ini sebagai gerbang review. Tiap item terkait satu kegagalan yang dijelaskan di atas, dan item yang belum dicentang adalah insiden yang tinggal menunggu tanggal.
Kode bisnis hanya memancarkan event; satu boundary ingest memetakan event ke channel dan template.
Baris notifikasi commit dalam transaksi yang sama dengan perubahan bisnis.
Dedup key unik, dibangun dari niat bukan percobaan, menjaga setiap insert.
Satu queue per channel dengan jalur critical, plus pengecekan preferensi dan suppression saat kirim.
Outcome bertipe; error permanen ke dead-letter queue, yang sementara backoff dengan jitter sampai batas.
Token bucket per provider, circuit breaker untuk failover, dan update status dari webhook yang hanya maju.
Jika hanya bisa mengerjakan tiga hal di hari pertama, kerjakan dedup key, pengecekan preferensi, dan klasifikasi retry. Ketiganya mencegah kesalahan yang paling terlihat, yaitu duplikat, pesan yang tidak diinginkan, dan menghantam alamat mati, dan sisanya bisa ditambahkan di balik kontrak yang sama.
Sistem notifikasi yang scalable sebagian besar adalah kumpulan penolakan: menolak mengirim dua kali, menolak mengirim ke orang yang sudah bilang tidak, menolak me-retry yang mustahil berhasil, dan menolak mempercayai urutan webhook. Bangun boundary dan constraint-nya lebih dulu, maka pilihan provider menjadi detail yang bisa diganti.