This episode builds a real-time notification system: notification types and delivery channels, the notification service architecture, user preferences, batching, and persistence of read and unread status.

Real-time notifications are the main reason many applications install WebSocket: the badge changes, a toast appears, the notification list grows without reloading the page. But behind that simple display is a system with many design decisions.
Episode 21 builds a real-time notification system: types and delivery channels, the notification service architecture, user preferences, delivery optimization with batching, and read-status storage. You will see patterns reusable in any application.
Notifications do not always arrive via WebSocket. Some common channels:
In-app : badge dan toast lewat WebSocket
Push : notifikasi sistem ke device saat offline
Email : ringkasan atau notifikasi penting
SMS : kode OTP dan notifikasi kritikalThe rule of thumb: WebSocket for notifications that must appear instantly, push for when the app is closed, and email or SMS as the fallback for important ones.
Not all notifications are equal. Tag them with a priority so delivery can be tuned.
const prioritas = {
kritikal: 0,
tinggi: 1,
normal: 2,
rendah: 3,
};kritikal is sent through all channels without waiting, rendah can be batched and sent hourly. Priority is the basis for delivery decisions throughout the system.
Separate the notification logic from the chat server so it does not disturb the application core.
app server -> message broker -> notification service -> WebSocket serverThe main application sends events to the broker, the notification service processes preferences and picks the channel, then forwards to the WebSocket server for real-time delivery. This separation lets notifications scale on their own and be tested separately.
Failed deliveries must not disappear silently.
const antrean = [];
async function kirim(notif) {
try {
await wsDelivery(notif);
return true;
} catch {
antrean.push(notif);
return false;
}
}antrean.push(notif) holds failed notifications for the next attempt. Combine it with a bounded retry, and do not forget to notify monitoring if the queue piles up.
Every user has their own preferences: which notifications, through which channels, and when not to be disturbed.
const pref = await db.preferensi.findUnique({
where: { userId: 42 },
});
const boleh = (notif) => {
if (pref.dnd && pref.dndAktif) return false;
if (pref.muteKategori.includes(notif.kategori)) return false;
return pref.channel[notif.kategori].includes(notif.channel);
};boleh(notif) checks three things: Do Not Disturb mode, muted categories, and the allowed channels per category. This decision runs on the server, not the client, so muting applies across all devices.
DND mode is usually time-based, for example no non-critical notifications between 22:00 and 07:00. Store the schedule in the database and let the system delay them, not discard them.
Sending fifteen likes one by one is wasteful.
const pending = new Map();
function tambah(notif) {
const list = pending.get(notif.userId) || [];
list.push(notif);
pending.set(notif.userId, list);
}
setInterval(() => {
pending.forEach((list, userId) => {
if (list.length === 1) {
kirimKeUser(userId, list[0]);
} else {
kirimKeUser(userId, {
type: "notif:batch",
items: list,
});
}
});
pending.clear();
}, 5000);pending accumulates notifications for 5 seconds then sends a single batch if there is more than one. This cuts hundreds of frames down to dozens without sacrificing the real-time feel.
If the user is active in the application, there is no need to send a push notification — in-app is enough. The system uses the presence from episode 11: online users receive via WebSocket, offline ones via push.
Notifications need to be stored so users can see their history.
const tersimpan = await db.notifikasi.create({
data: {
userId: 42,
teks: "Arman membalas komentarmu",
kategori: "komentar",
dibaca: false,
},
});The dibaca: false field enables querying unread notifications. When the user opens the list, the server marks them all as read and sends an event to the client to reset the badge.
Notifications pile up over time. Apply a policy: delete or archive notifications older than 90 days, and cap the maximum notifications per user. This keeps the database lean and queries fast.
Episode 21 assembled notifications end to end: types and channels, a separate architecture, respected preferences, frugal batching, and status that persists in the database.
Key takeaways:
In the next episode we build a live dashboard & data streaming: high-frequency updates, the delta update pattern, Chart.js and D3 integration, and rendering optimization.