As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let's talk about keeping things in sync. When you build a web app that feels alive—where a chat message pops up for everyone at once, a document is edited simultaneously, or a dashboard updates without a refresh—you're dealing with real-time data synchronization. It's what separates a static page from an interactive experience.
I've built several apps where this was the core challenge. The goal is always the same: make sure every user sees the same truth at roughly the same time, without them having to ask for it. Over time, certain reliable patterns have emerged to solve this. I'll walk you through seven of the most practical ones, explaining them as if we're building something together.
The most direct method is using a WebSocket. Think of it as a permanent telephone line opened between your user's browser and your server. Once the call is connected, either side can talk at any time. This is different from normal web traffic, where the browser must call the server, hang up, and call again for each new piece of information.
Here's a more robust way I might set up a WebSocket client. Notice how it handles the inevitable disconnections.
class WebSocketManager {
constructor(url, reconnectDelay = 3000) {
this.url = url;
this.reconnectDelay = reconnectDelay;
this.socket = null;
this.messageHandlers = new Set();
this.connect();
}
connect() {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
console.log('WebSocket connected.');
// Re-subscribe to any channels or send initial auth
this.socket.send(JSON.stringify({ event: 'auth', token: getUserToken() }));
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
// Notify all registered handlers
this.messageHandlers.forEach(handler => handler(data));
};
this.socket.onclose = () => {
console.log(`WebSocket closed. Reconnecting in ${this.reconnectDelay}ms...`);
setTimeout(() => this.connect(), this.reconnectDelay);
};
this.socket.onerror = (error) => {
console.error('WebSocket error:', error);
this.socket.close(); // Trigger onclose to reconnect
};
}
subscribe(handler) {
this.messageHandlers.add(handler);
// Return an unsubscribe function
return () => this.messageHandlers.delete(handler);
}
send(data) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(data));
} else {
console.warn('WebSocket not open. Message queued or dropped.');
}
}
}
// Using it in your app
const ws = new WebSocketManager('wss://api.myapp.com/live');
const unsubscribe = ws.subscribe((incomingData) => {
if (incomingData.type === 'NEW_MESSAGE') {
addMessageToChatUI(incomingData.payload);
}
});
// To send a message
ws.send({ type: 'POST_MESSAGE', text: 'Hello world!' });
Sometimes, the conversation only needs to flow one way. Imagine a live news ticker or a stock price feed. The server is a broadcaster, and the clients are just listeners. For this, Server-Sent Events (SSE) is a fantastic and often overlooked tool. It's simpler than WebSockets because it uses a standard HTTP connection that stays open, with the server sending a stream of messages down the pipe.
I find SSE wonderfully straightforward on the client side. The browser's EventSource API handles reconnection for you.
// Client-side: Listening to a stream
function setupEventStream(streamUrl) {
const eventSource = new EventSource(streamUrl);
// Listen for a specific event type
eventSource.addEventListener('stock-update', function(event) {
const update = JSON.parse(event.data);
// Update a specific UI element
document.getElementById(`stock-${update.symbol}`).textContent = `$${update.price}`;
});
// Listen for generic 'message' events (no event type specified by server)
eventSource.addEventListener('message', function(event) {
console.log('General update:', event.data);
});
eventSource.onerror = (err) => {
console.error('EventSource failed:', err);
// EventSource will auto-reconnect, but we might close and restart
eventSource.close();
setTimeout(() => setupEventStream(streamUrl), 2000);
};
}
setupEventStream('/api/live/stock-prices');
On the server (using Node.js with Express), setting up the stream is clean.
// Server-side: Sending an SSE stream
app.get('/api/live/stock-prices', (req, res) => {
// Set headers for an event stream
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Send a heartbeat every 30 seconds to keep the connection alive
const heartbeat = setInterval(() => {
res.write('data: heartbeat\n\n');
}, 30000);
// Example: Listen to a simulated data feed
const stockFeed = setInterval(() => {
const fakeUpdate = {
symbol: 'APP',
price: (Math.random() * 100 + 150).toFixed(2)
};
// Format is crucial: 'event:', 'data:', and two newlines.
res.write(`event: stock-update\ndata: ${JSON.stringify(fakeUpdate)}\n\n`);
}, 5000);
// Clean up when the client closes the connection
req.on('close', () => {
clearInterval(heartbeat);
clearInterval(stockFeed);
res.end();
});
});
Now, what happens when two people edit the same sentence in a document at the exact same time? This is the classic problem of collaborative editing, solved by a pattern called Operational Transformation (OT). The idea isn't to just take the last save and overwrite everything. It's to understand each individual change (like "insert 'cat' at position 5") and transform it against other concurrent changes so everyone ends up with the same document.
Let's make a tiny, simplified OT system for a text area. This isn't production-ready, but it shows the bones of the idea.
class SimpleTextCollaborator {
constructor(documentId) {
this.document = '';
this.pendingOperations = [];
this.isProcessing = false;
}
// Generate an operation from a user's action
createOperation(type, position, text = '') {
return {
id: Date.now() + Math.random(), // Simple unique ID
type, // 'insert' or 'delete'
position,
text,
timestamp: Date.now()
};
}
// Apply an operation locally immediately (optimistically)
applyLocal(operation) {
this.document = this.applyOperationToText(this.document, operation);
return operation;
}
// The core transformation logic
transformOperation(op, againstOp) {
// If 'againstOp' happened before the position of 'op', adjust 'op's position.
if (againstOp.type === 'insert' && againstOp.position <= op.position) {
return { ...op, position: op.position + againstOp.text.length };
}
if (againstOp.type === 'delete') {
// Deleting text before our position moves our position back.
const deleteEnd = againstOp.position + againstOp.text.length;
if (deleteEnd <= op.position) {
return { ...op, position: op.position - againstOp.text.length };
}
// If the deletion overlaps with our operation, it gets complex.
// This is a major simplification.
}
return op;
}
// Apply a transformed operation to the official document state
applyOperationToText(text, operation) {
const chars = text.split('');
if (operation.type === 'insert') {
chars.splice(operation.position, 0, ...operation.text.split(''));
} else if (operation.type === 'delete') {
chars.splice(operation.position, operation.text.length);
}
return chars.join('');
}
// Simulate sending to a server and receiving others' ops
async sendOperation(operation) {
// In reality, this would be a WebSocket send.
console.log('Sending:', operation);
// Simulate network delay and receiving a concurrent operation
setTimeout(() => {
const simulatedConcurrentOp = this.createOperation('insert', 0, 'OTHER ');
this.receiveOperation(simulatedConcurrentOp);
}, 100);
}
receiveOperation(incomingOp) {
// Transform the incoming op against all pending local ops
let transformedOp = { ...incomingOp };
this.pendingOperations.forEach(pendingOp => {
transformedOp = this.transformOperation(transformedOp, pendingOp);
});
// Apply the transformed operation to the true document
this.document = this.applyOperationToText(this.document, transformedOp);
// Update the UI with the new true document state
updateTextAreaUI(this.document);
}
}
Knowing who else is there makes an app feel shared. A presence system tracks who is "present" in a room, on a page, or editing a document. It usually works by having each client send a regular "I'm still here" signal (a heartbeat) to the server. If the heartbeats stop, the server marks that user as absent and tells everyone else.
Here's a conceptual client-side presence module.
class PresenceClient {
constructor(userId, channel) {
this.userId = userId;
this.channel = channel;
this.presentUsers = new Map(); // userId -> lastSeen timestamp
this.heartbeatInterval = null;
}
start() {
// Send immediate "join" event
this.sendHeartbeat();
// Start periodic heartbeats
this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), 25000);
// Listen for presence updates from the server
this.channel.on('presence_update', (data) => {
this.updatePresenceState(data.users);
});
}
sendHeartbeat() {
this.channel.send({
type: 'heartbeat',
userId: this.userId,
timestamp: Date.now()
});
}
updatePresenceState(userList) {
const now = Date.now();
const newMap = new Map();
userList.forEach(user => {
newMap.set(user.id, user.lastSeen);
// If a user is newly added, trigger an event
if (!this.presentUsers.has(user.id)) {
this.onUserJoined(user);
}
});
// Check for users who are no longer in the list
this.presentUsers.forEach((lastSeen, userId) => {
if (!newMap.has(userId)) {
this.onUserLeft(userId);
}
});
this.presentUsers = newMap;
}
onUserJoined(user) {
console.log(`${user.name} joined.`);
// UI update: add an avatar, a cursor, etc.
renderUserIndicator(user);
}
onUserLeft(userId) {
console.log(`User ${userId} left.`);
// UI update: remove their indicator
removeUserIndicator(userId);
}
stop() {
clearInterval(this.heartbeatInterval);
// Send a final "leave" event
this.channel.send({ type: 'leave', userId: this.userId });
}
}
For apps that need to work perfectly offline, like a note-taking app, we need a way for data to sync seamlessly later, even if changes were made on two disconnected devices. This is where Conflict-free Replicated Data Types (CRDTs) shine. They are data structures designed so that copies can be updated independently and will always merge into a consistent state when they reconnect.
Let's look at a simple CRDT for a counter that multiple people can increment.
class GCounter {
// A Grow-only Counter. Each replica tracks its own increments.
constructor(replicaId) {
this.replicaId = replicaId;
this.counts = new Map(); // replicaId -> count
this.counts.set(replicaId, 0);
}
increment() {
const current = this.counts.get(this.replicaId) || 0;
this.counts.set(this.replicaId, current + 1);
}
get value() {
let sum = 0;
for (let count of this.counts.values()) {
sum += count;
}
return sum;
}
// The merge function: take the maximum count from each replica.
merge(otherCounter) {
for (let [replicaId, otherCount] of otherCounter.counts) {
const myCount = this.counts.get(replicaId) || 0;
this.counts.set(replicaId, Math.max(myCount, otherCount));
}
}
// Prepare a copy to send to another replica
getPayload() {
return JSON.stringify({
replicaId: this.replicaId,
counts: Array.from(this.counts.entries())
});
}
// Receive and merge a payload
applyPayload(payload) {
const data = JSON.parse(payload);
const remoteCounter = new GCounter(data.replicaId);
remoteCounter.counts = new Map(data.counts);
this.merge(remoteCounter);
}
}
// Usage on Device A
const counterA = new GCounter('device-a');
counterA.increment();
counterA.increment();
// Usage on Device B (offline)
const counterB = new GCounter('device-b');
counterB.increment();
// They go online and sync
// Device B sends its payload to A
counterA.applyPayload(counterB.getPayload());
console.log(counterA.value); // Should be 3 (2 from A, 1 from B)
// Device A sends its payload to B
counterB.applyPayload(counterA.getPayload());
console.log(counterB.value); // Should also be 3. They have converged.
The fastest feedback a user can get is seeing their action happen immediately. Optimistic UI updates are a pattern where you update the screen with the expected result of an action before you even hear back from the server. If the server later says it failed, you roll back and show an error. This makes an app feel incredibly snappy.
Here's how you might structure this in a modern React hook.
import { useState, useCallback, useRef } from 'react';
function useOptimisticState(initialState) {
const [state, setState] = useState(initialState);
const [isPending, setIsPending] = useState(false);
// Keep a history for potential rollbacks
const historyRef = useRef([initialState]);
const optimisticUpdate = useCallback(async (updateFn, asyncAction) => {
// 1. Snapshot current state for rollback
const snapshot = state;
historyRef.current.push(snapshot);
// 2. Apply the update optimistically to local state
const newState = updateFn(state);
setState(newState);
setIsPending(true);
try {
// 3. Perform the real async action (e.g., API call)
const confirmedState = await asyncAction();
// 4. If successful, sync local state with confirmed server state
setState(confirmedState);
historyRef.current = [confirmedState]; // Reset history
} catch (error) {
// 5. On failure, roll back to the last known good state
console.error('Operation failed, rolling back:', error);
const lastGoodState = historyRef.current.pop(); // Get the snapshot
setState(lastGoodState);
// Optionally, show an error notification to the user
alert('Could not save. Please try again.');
} finally {
setIsPending(false);
}
}, [state]);
return [state, optimisticUpdate, isPending];
}
// Using it in a component
function TodoList() {
const [todos, updateTodos, isSaving] = useOptimisticState([]);
const handleAddTodo = async (text) => {
await updateTodos(
// Optimistic update function
(currentTodos) => [...currentTodos, { id: Date.now(), text, completed: false }],
// Real async action
async () => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text })
});
const savedTodo = await response.json();
// Return the final state based on server response
return [...todos, savedTodo];
}
);
};
return (
<div>
{isSaving && <div>Saving...</div>}
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
<button onClick={() => handleAddTodo('New Task')}>Add Task</button>
</div>
);
}
Networks fail. Phones lose signal. Users go into tunnels. A robust app doesn't just freeze or throw an error. Connection state management is the pattern of detecting offline status, queuing changes, and syncing when back online, all while keeping the user informed.
Let's build a simple network manager that acts as a gatekeeper for all our data operations.
class NetworkAwareService {
constructor() {
this.isOnline = window.navigator.onLine;
this.operationQueue = [];
this.listeners = new Set();
window.addEventListener('online', this.handleOnline.bind(this));
window.addEventListener('offline', this.handleOffline.bind(this));
}
handleOnline() {
console.log('Network: Online');
this.isOnline = true;
this.notifyListeners('online');
this.processQueue();
}
handleOffline() {
console.log('Network: Offline');
this.isOnline = false;
this.notifyListeners('offline');
}
// Try to execute an operation. If offline, queue it.
async execute(operation) {
if (this.isOnline) {
try {
return await operation();
} catch (error) {
// Retry logic could go here
throw error;
}
} else {
console.log('Offline. Queueing operation.');
return new Promise((resolve, reject) => {
this.operationQueue.push({ operation, resolve, reject });
});
}
}
// Process all queued operations when we come back online
async processQueue() {
while (this.operationQueue.length > 0) {
const { operation, resolve, reject } = this.operationQueue.shift();
try {
const result = await operation();
resolve(result);
} catch (error) {
reject(error);
}
}
}
// Example API call wrapped for network awareness
async apiPost(path, data) {
return this.execute(async () => {
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('API request failed');
return response.json();
});
}
notifyListeners(status) {
this.listeners.forEach(listener => listener(status));
}
onStatusChange(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
// App-wide instance
const NetworkService = new NetworkAwareService();
// Use it in your app
const saveButton = document.getElementById('save-button');
saveButton.addEventListener('click', async () => {
try {
const result = await NetworkService.apiPost('/api/data', { name: 'My Note' });
console.log('Saved:', result);
} catch (err) {
console.log('This will wait if we are offline.');
}
});
// Show a network status banner
const statusBanner = document.getElementById('network-status');
const unsubscribe = NetworkService.onStatusChange((status) => {
statusBanner.textContent = status === 'online' ? '' : '⚠ Working offline. Changes will save later.';
statusBanner.style.display = status === 'online' ? 'none' : 'block';
});
These seven patterns are tools. You won't use all of them in every project. A live sports scoreboard might just need SSE. A collaborative whiteboard needs WebSockets, OT, and Presence. A notes app needs CRDTs and Connection Management.
The key is understanding the problem you're solving: Is it about speed? Collaboration? Offline capability? Start with the simplest connection (maybe SSE or polling) and add complexity only when you need it. I often begin with optimistic updates and a basic WebSocket for notifications, as that covers a lot of ground with a good user experience.
Building real-time features can feel daunting, but breaking it down into these established patterns makes it manageable. Each one tackles a specific piece of the puzzle, and when combined, they create that magical, seamless experience where the app feels like it's thinking and reacting right along with you.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)