You've just been asked to build your first real-time feature: live order updates, stock tickers, or collaborative editing. The pressure is on, the timeline is tight, and you're about to learn that real-time systems are not just fast APIs running in a loop. Advice for engineers new to real-time comes down to one core principle: plan for failure first, optimize for speed second.
Start with SSE or polling
I see this mistake constantly: engineers jump straight to WebSockets because "real-time means WebSockets." That assumption costs weeks of debugging and infrastructure headaches you didn't need.
Here's what I learned the hard way. My first live feature was a ride-hailing dashboard showing driver locations. We went WebSockets immediately because it felt right. Within two weeks we were drowning in connection state management, debugging ghost connections, and spending half our sprint on reconnection logic that should have been trivial.
Start with Server-Sent Events (SSE) or HTTP polling instead. Both are simpler to reason about, easier to scale, and solve 80% of real-time problems. SSE is unidirectional but persistent—perfect for notifications, status updates, and data streams. Polling is stateless and works through any proxy or firewall.
Let me show you the difference. Here's a basic polling implementation:
// Client: polling for order updates
async function pollOrderStatus(orderId: string) {
while (true) {
try {
const response = await fetch(`/api/orders/${orderId}`);
const order = await response.json();
updateUI(order);
// Adaptive polling: back off if no changes
await new Promise(r => setTimeout(r, order.status === 'pending' ? 2000 : 10000));
} catch (error) {
console.error('Poll failed', error);
await new Promise(r => setTimeout(r, 5000)); // retry after 5s
}
}
}
And here's SSE, which is cleaner if your server can maintain connections:
// Client: SSE subscription
const eventSource = new EventSource(`/api/orders/${orderId}/stream`);
eventSource.addEventListener('update', (event) => {
const order = JSON.parse(event.data);
updateUI(order);
});
eventSource.addEventListener('error', () => {
eventSource.close();
// Reconnect after backoff
setTimeout(() => pollOrderStatus(orderId), 3000);
});
Polling adds latency and bandwidth overhead, yes. But you get connection resilience for free. When the network hiccups, the next poll just works. No reconnection state machine. No lingering half-open connections.
Only graduate to WebSockets when you genuinely need bidirectional, low-latency messaging at scale. That's maybe 20% of "real-time" features.
Plan for disconnection from day one
This is the insight that changed everything for me. Every connection will eventually drop. Not might drop. Will drop. Network hiccups, server restarts, load balancer timeouts, mobile signal loss—your code must assume disconnection is the normal state, not the exception.
I shipped a collaborative editing feature with pristine WebSocket handling but zero thought for "what if we lose the connection for 30 seconds?" Within a day of production, users lost edits. The client had buffered 15 keystrokes, the connection dropped, and we had no way to resync.
Here's my mental model now: every client needs a local queue of actions. When disconnected, queue them. When reconnected, replay them. When replaying, the server deduplicates using request IDs.
// Client-side action queue during disconnection
class ActionQueue {
private queue: Action[] = [];
private requestIdGenerator = 0;
async enqueue(action: any) {
const requestId = `${Date.now()}-${++this.requestIdGenerator}`;
this.queue.push({ ...action, requestId });
if (this.isConnected) {
await this.flush();
}
}
async flush() {
while (this.queue.length > 0) {
const batch = this.queue.splice(0, 10); // batch for efficiency
try {
await this.send('/api/batch', { actions: batch });
// Only remove if server acknowledges
} catch (error) {
this.queue.unshift(...batch); // put back on failure
throw error;
}
}
}
}
// Server: deduplicate by requestId
app.post('/api/batch', (req, res) => {
const processed = new Set();
for (const action of req.body.actions) {
if (processed.has(action.requestId)) continue; // already seen
applyAction(action);
processed.add(action.requestId);
}
res.json({ success: true });
});
The queue absorbs network flakiness. Users don't see lost updates; they see a temporary blur. That's acceptable. Losing data silently is not.
On the server side, always respond with a cursor or version number. Let clients know: "I've processed up to message 1523." Then the client knows which messages to retry. Without that, you're flying blind after reconnection.
Measure latency end to end
You can't optimize what you don't measure. I spent days tuning Kafka batch sizes and Redis operations that saved single-digit milliseconds, while the real bottleneck was the 500ms browser rendering time I never measured.
Set up latency instrumentation before you go live. Not after. Not as a "nice to have." Instrument it now.
Here's what I track for every real-time update:
| Measurement | Why it matters | Tool / Location |
|---|---|---|
| Event ingestion to broker | Detects input validation bottlenecks | Kafka producer metrics |
| Broker to worker processing | Reveals consumer lag and throughput ceiling | Kafka consumer lag monitoring |
| Worker to API response | Shows business logic cost | Application APM (DataDog, New Relic) |
| API response to client receipt | Network latency, serialization overhead | Browser Network tab + synthetic tests |
| Client receipt to DOM update | React render cost, layout thrashing | Chrome DevTools Performance tab |
I add client-side timestamps in every update packet:
// Server: tag the message with creation time
const message = {
orderId: '12345',
status: 'confirmed',
timestamp: Date.now(), // milliseconds since epoch
data: { ... }
};
kafka.send({ topic: 'orders', messages: [{ value: JSON.stringify(message) }] });
// Client: measure round-trip
addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
const latency = Date.now() - msg.timestamp;
analytics.histogram('realtime.latency', latency);
if (latency > 1000) {
console.warn('High latency detected:', latency);
}
});
Set acceptable latency thresholds with your product team. For stock tickers, 500ms is acceptable. For collaborative editing, 100ms matters. For driver location, 5-10 seconds is fine. Knowing this changes every architecture decision downstream.
Design for duplicates and gaps
After six months in production, I accepted this: your system will occasionally deliver the same message twice, and sometimes messages will be missing from a sequence. That's not a bug—it's physics.
The question is not "how do I prevent duplicates?" but "how do I tolerate them?"
Duplicates happen because of retries. If the client doesn't get an acknowledgment within 3 seconds, it retries. But the server actually processed the first one. Now you have two identical updates in flight.
Gaps happen when a consumer crashes, when Kafka retention purges old messages, or when a client rejoins after prolonged disconnection.
Build idempotent operations. Don't increment a counter; set it to a new value. Don't "add 5 to balance"; set balance to the authoritative value from your database.
// BAD: incremental, not idempotent
async function updateBalance(userId: string, delta: number) {
await db.query('UPDATE users SET balance = balance + $1 WHERE id = $2', [delta, userId]);
}
// GOOD: idempotent, uses version numbers
async function updateBalance(userId: string, newBalance: number, version: number) {
const result = await db.query(
'UPDATE users SET balance = $1, version = version + 1 WHERE id = $2 AND version = $3 RETURNING version',
[newBalance, userId, version]
);
if (result.rowCount === 0) {
throw new Error('Version mismatch - duplicate update?');
}
}
For gaps, store a high-water mark. If a client sees message 1247, then 1250, it knows 1248 and 1249 are missing. Depending on the feature, either request a full resync or fetch the gap from a REST endpoint.
// Client: detect gaps
let lastSeenSequence = -1;
function handleMessage(msg: any) {
if (msg.sequence > lastSeenSequence + 1) {
const gap = [];
for (let i = lastSeenSequence + 1; i < msg.sequence; i++) {
gap.push(i);
}
console.warn('Gap detected, fetching:', gap);
// Fetch missing messages from REST endpoint
fetchMessageRange(gap[0], gap[gap.length - 1]);
}
lastSeenSequence = msg.sequence;
processMessage(msg);
}
Designing for duplicates and gaps means your system stays correct even under network chaos. That's what separates production-ready from "works in my test environment."
Common mistakes
- Assuming the first connection is representative: Your local testing will never hit the connection flakiness production sees. Assume it's broken from day one.
- Forgetting to close connections: WebSocket connections are expensive. One leaked connection per user scales badly. Always clean up in component unmount or when navigating away.
- Streaming raw events without aggregation: If you're sending 1000 tiny updates per second, your client UI can't keep up anyway. Buffer and batch on the server before sending.
- No backpressure handling: If the client can't keep up, push back. Tell the server to slow down or batch differently. Don't silently drop messages.
- Treating real-time as an afterthought: Real-time architecture decisions ripple through your entire system. Plan for it in service design, not as a layer on top.
Key takeaways
- Start with polling or SSE. WebSockets are seductive but come with connection management complexity you probably don't need yet.
- Assume every connection will disconnect. Build a client-side queue and server-side deduplication from day one—not after production incidents.
- Measure latency across all layers: broker, worker, network, browser rendering. You can't optimize blind.
- Design for duplicates (use idempotent operations and version numbers) and gaps (detect and resync). Perfect message delivery is impossible; survivable delivery is achievable.
- Real-time is not primarily about speed; it's about resilience under unreliable networks and graceful degradation when things go wrong.






Comments
Loading comments…