Learn WebSocket - Real-Time Notifications System
Episode 21 of 34

Learn WebSocket - Real-Time Notifications System

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

Types and Delivery Channels

Multi-Channel Delivery

Notifications do not always arrive via WebSocket. Some common channels:

Notification delivery 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 kritikal

The 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.

Notification Priority

Not all notifications are equal. Tag them with a priority so delivery can be tuned.

JSNotification priorities
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.

Architecture

A Separate Notification Service

Separate the notification logic from the chat server so it does not disturb the application core.

Notification architecture
app server -> message broker -> notification service -> WebSocket server

The 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.

Queue and Retry

Failed deliveries must not disappear silently.

JSQueue with retry
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.

User Preferences

Settings and Mute

Every user has their own preferences: which notifications, through which channels, and when not to be disturbed.

JSUser notification preferences
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.

Do Not Disturb

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.

Delivery Optimization

Batching Notifications

Sending fifteen likes one by one is wasteful.

JSBatching notifications per user
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.

Presence-Based Delivery

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.

Persistence

Read and Unread Status

Notifications need to be stored so users can see their history.

JSStoring notification status
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.

Expiry and Archiving

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.

Closing

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:

  • Notifications have several channels with different speeds and reach.
  • Separate the notification service from the main application.
  • Preferences and DND are evaluated on the server so they apply across devices.
  • Batching cuts network load for large notification volumes.
  • Presence decides whether push is needed or in-app is enough.
  • Store read status and apply an expiry policy.

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.