Stel je voor: een gebruiker past een document aan, en zijn collega ziet de wijziging direct verschijnen. Een dashboard dat automatisch bijwerkt wanneer er nieuwe data binnenkomt. Een notificatiesysteem dat meldingen pusht zonder dat de pagina ververst hoeft te worden. Dit zijn geen luxefeatures meer — het is wat gebruikers verwachten van moderne SaaS-applicaties.
In dit artikel duiken we diep in de technologieën achter real-time functionaliteit, wanneer je welke aanpak kiest, en hoe je dit schaalbaar implementeert zonder je architectuur onnodig complex te maken.
Waarom real-time belangrijk is voor SaaS
Real-time features verhogen de perceived performance van je applicatie enorm. Onderzoek van Google toont aan dat gebruikers binnen 100ms feedback verwachten op hun acties. Maar het gaat verder dan snelheid:
- Hogere engagement: Gebruikers blijven langer actief wanneer data live bijwerkt
- Betere samenwerking: Teams kunnen tegelijkertijd in dezelfde omgeving werken
- Lagere churn: Applicaties die "levend" aanvoelen worden minder snel vervangen
- Operationele waarde: Real-time dashboards en alerts stellen gebruikers in staat sneller te reageren
Volgens een analyse van Intercom hebben SaaS-producten met real-time collaborative features een 23% hogere retentie dan producten zonder.
De drie pijlers: Polling, SSE en WebSockets
1. Short Polling — de simpele start
Bij short polling stuurt de client periodiek een HTTP-request naar de server om te checken of er nieuwe data is.
// Simpele polling implementatie
function startPolling(endpoint: string, interval: number = 5000) {
const poll = async () => {
try {
const response = await fetch(endpoint);
const data = await response.json();
handleUpdate(data);
} catch (error) {
console.error('Polling error:', error);
}
};
return setInterval(poll, interval);
}
Wanneer polling de juiste keuze is:
- Prototyping en MVP-fase
- Data die hooguit elke 30+ seconden verandert
- Situaties waar de infrastructuur geen persistente verbindingen ondersteunt
- Eenvoudige statuscontroles (is de betaling verwerkt?)
Nadelen:
- Inefficiënt: de meeste requests leveren geen nieuwe data op
- Vertraging: updates worden pas zichtbaar na het volgende poll-interval
- Serverbelasting schaalt lineair met het aantal gebruikers
2. Server-Sent Events (SSE) — de onderschatte middenweg
SSE is een eenvoudig protocol waarbij de server data kan pushen naar de client via een langlopende HTTP-verbinding. Het is één-directioneel (server → client), maar dat is voor veel use cases precies wat je nodig hebt.
// Next.js API route met SSE
// app/api/events/route.ts
export async function GET(request: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (data: object) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
};
// Luister naar database changes of message queue
const unsubscribe = eventBus.subscribe('updates', (event) => {
send(event);
});
// Heartbeat om de verbinding open te houden
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
}, 30000);
request.signal.addEventListener('abort', () => {
unsubscribe();
clearInterval(heartbeat);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
// Client-side SSE
const eventSource = new EventSource('/api/events');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data);
};
eventSource.onerror = (error) => {
// EventSource reconnect automatisch
console.log('SSE verbinding wordt hersteld...');
};
Voordelen van SSE:
- Automatische reconnect ingebouwd in de browser
- Werkt over standaard HTTP (geen speciale proxy-configuratie)
- Veel simpeler dan WebSockets voor uni-directionele communicatie
- Ondersteunt event types en IDs voor at-least-once delivery
Ideaal voor:
- Live dashboards en monitoring
- Notificatiesystemen
- Feed-updates (social, activiteit)
- Progress indicators voor langlopende taken
- AI streaming responses (denk aan ChatGPT-achtige interfaces)
3. WebSockets — full-duplex communicatie
WebSockets bieden bi-directionele communicatie over een enkele TCP-verbinding. Dit is de krachtigste optie, maar ook de meest complexe.
// Server-side WebSocket met ws library
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
// Room-based broadcasting
const rooms = new Map<string, Set<WebSocket>>();
wss.on('connection', (ws, request) => {
const userId = authenticateFromRequest(request);
const roomId = extractRoomId(request.url);
// Join room
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
ws.on('message', (raw) => {
const message = JSON.parse(raw.toString());
switch (message.type) {
case 'cursor_move':
broadcastToRoom(roomId, {
type: 'cursor_update',
userId,
position: message.position,
}, ws);
break;
case 'document_edit':
const result = applyEdit(message.edit);
broadcastToRoom(roomId, {
type: 'document_change',
edit: result,
userId,
}, ws);
break;
}
});
ws.on('close', () => {
rooms.get(roomId)?.delete(ws);
broadcastToRoom(roomId, { type: 'user_left', userId });
});
});
function broadcastToRoom(roomId: string, data: object, exclude?: WebSocket) {
rooms.get(roomId)?.forEach((client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
});
}
Ideaal voor:
- Collaborative editing (Google Docs-achtig)
- Chat en messaging
- Multiplayer functionaliteit
- Live cursor tracking
- Elke situatie waar de client ook data naar de server moet sturen in real-time
Beslismatrix: welke technologie wanneer?
| Criterium | Polling | SSE | WebSockets |
|---|---|---|---|
| Richting | Client → Server | Server → Client | Bi-directioneel |
| Complexiteit | Laag | Medium | Hoog |
| Latency | Hoog (interval) | Laag | Zeer laag |
| Schaalbaarheid | Matig | Goed | Complex |
| Reconnect | Handmatig | Automatisch | Handmatig |
| Binary data | Nee | Nee | Ja |
| HTTP/2 compatible | Ja | Ja | Nee (upgrade) |
Vuistregel: Begin met SSE tenzij je bi-directionele communicatie nodig hebt. Polling voor MVP's. WebSockets alleen wanneer het echt nodig is.
Schaalbaar maken: de pub/sub laag
Het grootste probleem met real-time op schaal: als je meerdere server-instances draait (en dat doe je), hoe zorg je dan dat een event op server A ook bij clients op server B aankomt?
De oplossing is een pub/sub laag tussen je servers.
// Redis pub/sub als backbone voor multi-instance real-time
import Redis from 'ioredis';
class RealTimeBroker {
private publisher: Redis;
private subscriber: Redis;
private localSubscriptions = new Map<string, Set<(data: any) => void>>();
constructor(redisUrl: string) {
this.publisher = new Redis(redisUrl);
this.subscriber = new Redis(redisUrl);
this.subscriber.on('message', (channel, message) => {
const handlers = this.localSubscriptions.get(channel);
if (handlers) {
const data = JSON.parse(message);
handlers.forEach(handler => handler(data));
}
});
}
async publish(channel: string, data: object) {
await this.publisher.publish(channel, JSON.stringify(data));
}
subscribe(channel: string, handler: (data: any) => void) {
if (!this.localSubscriptions.has(channel)) {
this.localSubscriptions.set(channel, new Set());
this.subscriber.subscribe(channel);
}
this.localSubscriptions.get(channel)!.add(handler);
return () => {
this.localSubscriptions.get(channel)?.delete(handler);
if (this.localSubscriptions.get(channel)?.size === 0) {
this.subscriber.unsubscribe(channel);
this.localSubscriptions.delete(channel);
}
};
}
}
Alternatieven voor Redis pub/sub
- NATS: Lichtgewicht, hoge throughput, ingebouwde JetStream voor persistentie
- Kafka: Voor hoge volumes met durability-garanties (vaak overkill voor real-time UI)
- Ably/Pusher: Managed services die de complexiteit wegnemen
- Supabase Realtime: Ingebouwde real-time laag bovenop PostgreSQL
Praktijkvoorbeeld: real-time notificatiesysteem
Laten we een compleet notificatiesysteem bouwen dat schaalbaar is en werkt met SSE:
// lib/notifications.ts
import { RealTimeBroker } from './broker';
import { prisma } from './prisma';
export class NotificationService {
constructor(private broker: RealTimeBroker) {}
async send(userId: string, notification: {
title: string;
body: string;
type: 'info' | 'warning' | 'success' | 'error';
actionUrl?: string;
}) {
// 1. Persisteer in database
const saved = await prisma.notification.create({
data: { userId, ...notification, read: false },
});
// 2. Publiceer via pub/sub voor real-time delivery
await this.broker.publish(`notifications:${userId}`, {
type: 'new_notification',
notification: saved,
});
return saved;
}
async markAsRead(notificationId: string, userId: string) {
await prisma.notification.update({
where: { id: notificationId, userId },
data: { read: true },
});
await this.broker.publish(`notifications:${userId}`, {
type: 'notification_read',
notificationId,
});
}
}
// React hook voor real-time notificaties
function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
fetch('/api/notifications')
.then(res => res.json())
.then(data => {
setNotifications(data.notifications);
setUnreadCount(data.unreadCount);
});
const eventSource = new EventSource('/api/notifications/stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'new_notification':
setNotifications(prev => [data.notification, ...prev]);
setUnreadCount(prev => prev + 1);
if (Notification.permission === 'granted') {
new Notification(data.notification.title, {
body: data.notification.body,
});
}
break;
case 'notification_read':
setNotifications(prev =>
prev.map(n =>
n.id === data.notificationId ? { ...n, read: true } : n
)
);
setUnreadCount(prev => Math.max(0, prev - 1));
break;
}
};
return () => eventSource.close();
}, []);
return { notifications, unreadCount };
}
Performance tips voor productie
1. Beperk het aantal verbindingen
// Multiplexing: één verbinding per client, meerdere channels
const eventSource = new EventSource(
'/api/events?channels=notifications,dashboard,chat'
);
2. Throttle hoge-frequentie updates
// Server-side throttling voor cursor updates
import { throttle } from 'lodash';
const throttledBroadcast = throttle((roomId, data) => {
broadcastToRoom(roomId, data);
}, 50); // Max 20 updates per seconde
3. Graceful degradation
// Fallback van WebSocket → SSE → Polling
function createConnection(url: string) {
if ('WebSocket' in window) {
try { return new WebSocketConnection(url); }
catch { /* fall through */ }
}
if ('EventSource' in window) {
return new SSEConnection(url);
}
return new PollingConnection(url);
}
4. Connection management op schaal
- Gebruik een connection limiet per user (bijv. max 5 tabs)
- Implementeer backpressure: als de client niet snel genoeg consumeert, buffer of drop messages
- Monitor het aantal actieve verbindingen per server-instance
- Overweeg sticky sessions of een dedicated WebSocket-service
Veelgemaakte fouten
- WebSockets gebruiken waar SSE volstaat — onnodige complexiteit
- Geen reconnect-logica — verbindingen vallen uit, altijd
- Alle data real-time maken — niet alles hoeft instant te zijn
- Geen authenticatie op de WebSocket-verbinding — vergeet niet te valideren bij connect én bij elke message
- Geen rate limiting op inkomende WebSocket messages — bescherm je server tegen overbelasting
Conclusie
Real-time functionaliteit is een krachtige manier om je SaaS-product te laten opvallen. De sleutel is om de juiste technologie te kiezen voor je use case:
- Start simpel met SSE voor server-naar-client updates
- Gebruik WebSockets alleen voor bi-directionele communicatie
- Investeer vroeg in een pub/sub laag voor schaalbaarheid
- Implementeer reconnect-logica vanaf dag één
De technologie is volwassen, de tooling is beschikbaar, en je gebruikers verwachten het. Begin klein, meet het effect op engagement, en bouw van daaruit verder.
Wil je real-time features toevoegen aan je SaaS maar weet je niet waar te beginnen? Neem contact met ons op voor een vrijblijvend gesprek over de mogelijkheden.