🔔 Real-time Notifications
A new message, a shipped order, a comment that mentions you — these arrive on your screen the instant they happen, no refresh required. That "aliveness" is what users remember. In this lesson you'll assemble the real-time skills from this week into a complete notification system: a server that targets the right user, a toast UI that renders the alert, and delivery that survives the recipient being offline.
Week 10 · Tuesday: Real-time Features · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe the flow of a notification from a backend event to the right user's screen
- Build a server-side
NotificationServicethat maps users to sockets and targets them precisely - Trigger notifications from ordinary REST routes as well as socket events
- Render accessible, auto-dismissing toast notifications on the client
- Persist notifications so offline users receive them on reconnect
- Respect user preferences and quiet hours before sending anything
Estimated Time: 75 minutes
Practice: Build a notification service, a toast client, and an offline-sync flow.
In This Lesson
Why Notifications Matter
For years, staying up to date meant hitting refresh. The old request/response cycle forced users to pull for news, which is a poor experience on three counts: users don't know when something changed, frequent polling wastes bandwidth and server cycles, and any update is stale by up to a full poll interval.
Real-time notifications flip this to a push model. The server pushes the moment an event happens, so the interface feels responsive and alive. You've already built the transport — Socket.IO — in the previous two lessons. Now we design the system that uses it well.
💡 Where you'll see this pattern
- Social: new messages, likes, comments, follow requests
- E-commerce: order shipped, out for delivery, price drop
- Collaboration: a teammate edited the doc, a comment mentioned you
- Finance: a transaction cleared, a price alert triggered
The Notification Flow
A notification isn't just an emit — it's a small pipeline. Something happens in your backend (an order ships, a comment is posted). A notification service decides who should hear about it and how, then routes it through Socket.IO to the right sockets. Seeing the whole path first makes the code that follows obvious.
The crucial insight is the mapping problem. Your app thinks in user ids ("notify user 42"), but Socket.IO thinks in socket ids — and one user may have several sockets open at once (laptop, phone, a second tab). The notification service's main job is to bridge that gap: given a user id, find all of that user's live sockets.
A Server-Side Notification Service
Let's build a small class that owns the user-to-socket mapping and exposes clean methods to notify a user, a room, or everyone. A tidy Socket.IO trick: have each socket join a room named after its user id on connect. Then "notify user 42" is simply "emit to room user:42" — and Socket.IO handles the multiple-devices problem for you, even across servers with the Redis adapter.
// notification-service.js
class NotificationService {
constructor(io) {
this.io = io;
}
// Build a consistent per-user room name
userRoom(userId) {
return `user:${userId}`;
}
// Send to every device of one user
sendToUser(userId, type, data) {
this.io.to(this.userRoom(userId)).emit('notification', {
type,
data,
at: new Date().toISOString(),
});
}
// Send to a named group (e.g. 'delivery-team')
sendToRoom(room, type, data) {
this.io.to(room).emit('notification', {
type,
data,
at: new Date().toISOString(),
});
}
// Send to everyone connected
broadcast(type, data) {
this.io.emit('notification', {
type,
data,
at: new Date().toISOString(),
});
}
}
module.exports = NotificationService;
Notice every notification has the same envelope: a type (so the client knows how to render it), a data payload, and a timestamp. A consistent shape keeps the client simple — it routes on type and never has to guess.
✅ Why the per-user room beats a manual Map
You could track a Map<userId, Set<socketId>> yourself, but you'd have to add and remove entries on every connect and disconnect, and it would only work on a single server. Joining a user:<id> room delegates all of that bookkeeping to Socket.IO and works transparently across a Redis-backed cluster.
Triggering from REST & Sockets
Notifications rarely originate from the socket itself. More often, some other part of your system — an order processor, a payment webhook, an admin action — needs to fire one. So expose the service through both an HTTP route (for backend-to-backend triggers) and the normal socket lifecycle.
// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const NotificationService = require('./notification-service');
const app = express();
app.use(express.json());
const server = http.createServer(app);
const io = new Server(server);
const notifications = new NotificationService(io);
// --- Socket lifecycle: authenticate, then join the user's room ---
io.use((socket, next) => {
const userId = verifyToken(socket.handshake.auth.token); // your auth
if (!userId) return next(new Error('Unauthorized'));
socket.data.userId = userId;
next();
});
io.on('connection', (socket) => {
const { userId } = socket.data;
socket.join(`user:${userId}`); // <-- the key line
socket.emit('ready', { userId });
});
// --- REST trigger: any backend service can POST to notify a user ---
app.post('/api/notify/user/:userId', (req, res) => {
const { type, data } = req.body;
notifications.sendToUser(req.params.userId, type, data);
res.json({ ok: true });
});
Now a real business event — say, an order status change — becomes a couple of readable lines:
// Somewhere in your order-processing code
orderEvents.on('status-change', (order) => {
notifications.sendToUser(order.userId, 'order-update', {
orderId: order.id,
status: order.status,
message: statusMessage(order.status),
});
// Deliveries also alert a shared team room
if (order.status === 'out-for-delivery') {
notifications.sendToRoom('delivery-team', 'new-delivery', {
orderId: order.id,
address: order.shippingAddress,
});
}
});
function statusMessage(status) {
const messages = {
processing: 'Your order is being processed.',
shipped: 'Good news — your order has shipped!',
'out-for-delivery': 'Your order is out for delivery.',
delivered: 'Delivered. Enjoy!',
};
return messages[status] ?? `Order status: ${status}`;
}
The Toast UI
On the client, a notification should be noticeable but not intrusive — the classic "toast" that slides in, lingers a few seconds, and slides away. We'll build a small, dependency-free toast manager and wire it to the incoming notification event.
Receiving and routing notifications
// notification-client.js
const socket = io({ auth: { token: localStorage.getItem('jwt') } });
socket.on('notification', (note) => {
// Route on the type field we standardized on the server
switch (note.type) {
case 'order-update':
toast.info(`Order #${note.data.orderId}`, note.data.message);
break;
case 'message':
toast.info(`New message from ${note.data.sender}`, note.data.preview);
break;
case 'friend-request':
toast.success('Friend request', `${note.data.name} wants to connect`);
break;
default:
toast.info('Notification', JSON.stringify(note.data));
}
});
A minimal toast manager
The manager creates a fixed container once, then appends a toast element per notification and removes it after a timeout. Crucially, it builds nodes with textContent — never innerHTML with raw data — so a malicious message can't inject script.
class ToastManager {
constructor() {
this.container = document.createElement('div');
this.container.id = 'toast-container';
this.container.setAttribute('role', 'status'); // announced by
this.container.setAttribute('aria-live', 'polite'); // screen readers
document.body.appendChild(this.container);
}
show(title, message, type = 'info', duration = 5000) {
const el = document.createElement('div');
el.className = `toast toast-${type}`;
const h = document.createElement('h4');
h.textContent = title; // textContent = safe against XSS
const p = document.createElement('p');
p.textContent = message;
el.append(h, p);
this.container.appendChild(el);
setTimeout(() => this.dismiss(el), duration);
return el;
}
dismiss(el) {
el.classList.add('toast-hiding');
el.addEventListener('transitionend', () => el.remove(), { once: true });
}
info(t, m) { return this.show(t, m, 'info'); }
success(t, m) { return this.show(t, m, 'success'); }
error(t, m) { return this.show(t, m, 'error'); }
}
const toast = new ToastManager();
⚠️ Never render notification text as HTML
Notification data often contains user-generated content — a message preview, someone's display name. If you drop it into innerHTML, an attacker can smuggle a <script> or a malicious onerror in and run code in every recipient's browser. Build DOM nodes and assign textContent, exactly as above.
♿ Make toasts accessible
Toasts vanish on their own, which is a trap for keyboard and screen-reader users. Put the container in an aria-live="polite" region (done above) so new toasts are announced, keep durations generous, and provide a manual close button. Never convey only critical information through a toast that disappears.
Offline Delivery & Preferences
Two problems separate a toy demo from a real system. First, what if the user is offline when the event fires? An emit to a room with no sockets simply vanishes. Second, users want control — they don't all want every alert at every hour.
Persist, then deliver on reconnect
The fix for offline delivery: always store the notification in a database, then try to deliver it live. When a user reconnects, replay anything they missed.
class DurableNotificationService extends NotificationService {
constructor(io, db) {
super(io);
this.db = db;
}
async sendToUser(userId, type, data) {
// 1. Persist first, so nothing is ever lost
const record = await this.db.notifications.insert({
userId, type, data, delivered: false, read: false, createdAt: new Date(),
});
// 2. Then attempt live delivery
super.sendToUser(userId, type, data);
return record;
}
// Called when a user reconnects
async replayUndelivered(userId, socket) {
const missed = await this.db.notifications.find({ userId, delivered: false });
if (missed.length) {
socket.emit('notification-sync', missed);
await this.db.notifications.markDelivered(missed.map((n) => n.id));
}
}
}
// On connect, catch the user up
io.on('connection', async (socket) => {
const { userId } = socket.data;
socket.join(`user:${userId}`);
await notifications.replayUndelivered(userId, socket);
});
Respect user preferences
Before sending, check whether the user actually wants this. A preferences check gates every send — including a "do not disturb" quiet-hours window.
async function shouldSend(db, userId, type) {
const prefs = await db.preferences.findOne({ userId });
if (!prefs) return true; // default: send
if (prefs.channels?.[type] === false) return false; // muted this type
// Quiet hours (stored as minutes-since-midnight, user's timezone)
if (prefs.quietHours?.enabled) {
const now = minutesSinceMidnight(prefs.timezone);
const { start, end } = prefs.quietHours;
const inWindow = start < end
? now >= start && now < end // same-day window
: now >= start || now < end; // overnight window
if (inWindow) return false;
}
return true;
}
💡 Push notifications pick up where sockets stop
A Socket.IO notification only reaches a user with the app open. To reach them when the tab is closed, layer on the Web Push API (via a service worker) or a service like Firebase Cloud Messaging. A common pattern: try the socket first, and if the user has no live connection, fall back to a push notification.
Practice & Quiz
🏋️ Exercise 1: Target a user across devices
Goal: Write sendToUser(userId, type, data) that reaches all of a user's open connections at once, using the per-user room convention.
💡 Hint
Every socket joins user:<id> on connect. So emitting to that room hits every device. Wrap the payload in the standard { type, data, at } envelope.
✅ Solution
sendToUser(userId, type, data) {
this.io.to(`user:${userId}`).emit('notification', {
type,
data,
at: new Date().toISOString(),
});
}
🏋️ Exercise 2: A safe toast
Goal: Render a toast whose title and message come from untrusted notification data — without opening an XSS hole.
💡 Hint
Create elements and assign textContent. Avoid innerHTML entirely when inserting data you didn't generate.
✅ Solution
function toast(title, message) {
const el = document.createElement('div');
el.className = 'toast';
const h = document.createElement('h4');
h.textContent = title; // safe
const p = document.createElement('p');
p.textContent = message; // safe
el.append(h, p);
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), 5000);
}
🎯 Quick Quiz
Question 1: Why have each socket join a room named after its user id?
Question 2: A user is offline when an event fires. How do you make sure they still get the notification?
Question 3: What's the safe way to put a message preview into a toast?
Best Practices & Pitfalls
✅ Do
- Give every notification a consistent
{ type, data, timestamp }envelope - Route to users via a per-user room so all their devices are covered
- Persist notifications, then deliver — so offline users never miss anything
- Check user preferences and quiet hours before sending
- Render text with
textContentand place toasts in anaria-liveregion - Throttle or group bursts so you don't bury the user in toasts
❌ Don't
- Assume an
emitreached anyone — it silently vanishes if no socket is listening - Inject notification data with
innerHTML— that's a direct XSS vector - Convey critical, must-see information through a toast that auto-dismisses
- Send to a single socket id when the user might be on several devices
- Ignore preferences — unwanted notifications train users to disable them entirely
⚠️ Notification fatigue is real
The fastest way to make users mute your app is to over-notify. Group related events ("3 new comments" instead of three separate toasts), respect quiet hours, and give users granular control over what they receive. A notification the user didn't want is worse than no notification at all.
Summary
🎉 Key Takeaways
- A notification is a pipeline: event → service → Socket.IO → the right sockets
- One user has many sockets; join each to a
user:<id>room so a single emit reaches every device - A
NotificationServiceexposessendToUser,sendToRoom, andbroadcastwith one envelope shape - Trigger notifications from REST routes and business events, not just socket messages
- Render toasts with
textContentin anaria-liveregion — safe and accessible - Persist then deliver for offline users, and always check preferences and quiet hours first
📚 Additional Resources
- Socket.IO — Rooms (per-user targeting)
- MDN — The Push API
- MDN — The Notifications API
- MDN — The ARIA status role (live regions)
🚀 What's Next?
You've completed the real-time features track — transport, library, and a full notification system. Next we shift to another everyday full-stack challenge: getting large files from the browser to the server efficiently, in File Upload Strategies.
🎉 Your app is alive!
Events now flow from anywhere in your backend to exactly the right user, on every device, online or off. That's the difference users feel.