Real-Time Data Sync Architectures: WebSockets, SSE, CRDTs, and Beyond
Author
ZTABS Team
Date Published
Real-time features have shifted from "nice-to-have" to table stakes. Users expect collaborative editing, live notifications, instant messaging, and dashboards that update without refreshing. Building these real-time web features reliably requires understanding the transport layer, the synchronization model, and the trade-offs each approach makes.
This guide covers the architecture behind real-time data sync — from low-level protocols to production platforms — so you can choose the right approach for your application.
Transport Protocols
The foundation of any real-time system is the transport layer — how data moves between client and server.
WebSockets
WebSockets provide a persistent, full-duplex connection between client and server. Once the HTTP handshake upgrades to WebSocket, both sides can send messages at any time without the overhead of new HTTP requests.
// Server: WebSocket with ws library
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map<string, Set<WebSocket>>();
wss.on("connection", (ws, req) => {
const roomId = new URL(req.url ?? "", "http://localhost").searchParams.get("room");
if (!roomId) return ws.close(1008, "Room ID required");
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
const room = rooms.get(roomId);
if (!room) return;
for (const client of room) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
}
});
ws.on("close", () => {
rooms.get(roomId)?.delete(ws);
if (rooms.get(roomId)?.size === 0) rooms.delete(roomId);
});
});
// Client: connecting and handling messages
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
interface UseWebSocketOptions {
url: string;
onMessage: (data: unknown) => void;
reconnectInterval?: number;
}
export function useWebSocket({
url,
onMessage,
reconnectInterval = 3000,
}: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const connect = useCallback(() => {
const ws = new WebSocket(url);
ws.onopen = () => setIsConnected(true);
ws.onclose = () => {
setIsConnected(false);
setTimeout(connect, reconnectInterval);
};
ws.onmessage = (event) => onMessage(JSON.parse(event.data));
wsRef.current = ws;
}, [url, onMessage, reconnectInterval]);
useEffect(() => {
connect();
return () => wsRef.current?.close();
}, [connect]);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
return { send, isConnected };
}
Best for: chat, collaborative editing, multiplayer games, bidirectional data streams.
Server-Sent Events (SSE)
SSE is a simpler protocol for one-way real-time updates from server to client. It uses standard HTTP, works through proxies and load balancers, and reconnects automatically.
// Next.js Route Handler with SSE
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const sendEvent = (data: unknown, eventType = "message") => {
const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(payload));
};
sendEvent({ status: "connected" }, "connected");
const interval = setInterval(async () => {
const updates = await getLatestUpdates();
if (updates.length > 0) {
sendEvent(updates, "update");
}
}, 1000);
request.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// Client: consuming SSE
"use client";
import { useEffect, useState } from "react";
export function useLiveUpdates<T>(url: string) {
const [data, setData] = useState<T[]>([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.addEventListener("connected", () => setIsConnected(true));
eventSource.addEventListener("update", (event) => {
setData(JSON.parse(event.data));
});
eventSource.onerror = () => setIsConnected(false);
return () => eventSource.close();
}, [url]);
return { data, isConnected };
}
Best for: live dashboards, notification feeds, stock tickers, build status updates.
Protocol Comparison
| Feature | WebSockets | SSE | HTTP Polling | |---------|-----------|-----|-------------| | Direction | Bidirectional | Server → Client | Client → Server (pull) | | Protocol | ws:// / wss:// | HTTP | HTTP | | Reconnection | Manual | Automatic | N/A | | Binary data | Yes | No (text only) | Yes | | HTTP/2 multiplexing | No (separate connection) | Yes | Yes | | Proxy/LB compatibility | Needs configuration | Works natively | Works natively | | Connection overhead | One-time handshake | Standard HTTP | Per-request | | Browser support | Universal | Universal (except IE) | Universal |
Conflict Resolution: CRDTs and Operational Transforms
When multiple users edit the same data simultaneously, conflicts are inevitable. Two approaches handle this: Operational Transformation (OT) and Conflict-free Replicated Data Types (CRDTs).
Operational Transformation (OT)
OT was pioneered by Google Docs. It transforms concurrent operations so they produce a consistent result regardless of the order they arrive.
interface Operation {
type: "insert" | "delete";
position: number;
content?: string;
length?: number;
userId: string;
timestamp: number;
}
function transformOperation(
incoming: Operation,
applied: Operation
): Operation {
if (incoming.position <= applied.position) return incoming;
const offset =
applied.type === "insert"
? (applied.content?.length ?? 0)
: -(applied.length ?? 0);
return { ...incoming, position: incoming.position + offset };
}
OT requires a central server to order operations. This makes it reliable but creates a single point of failure and a scalability bottleneck.
Conflict-free Replicated Data Types (CRDTs)
CRDTs are data structures that can be updated independently and concurrently without coordination, and always converge to a consistent state when merged. No central server required.
// Simplified Last-Writer-Wins Register
interface LWWRegister<T> {
value: T;
timestamp: number;
nodeId: string;
}
function mergeLWW<T>(
local: LWWRegister<T>,
remote: LWWRegister<T>
): LWWRegister<T> {
if (remote.timestamp > local.timestamp) return remote;
if (remote.timestamp === local.timestamp) {
return remote.nodeId > local.nodeId ? remote : local;
}
return local;
}
// Grow-Only Counter (distributed counting)
interface GCounter {
counts: Record<string, number>;
}
function incrementGCounter(counter: GCounter, nodeId: string): GCounter {
return {
counts: {
...counter.counts,
[nodeId]: (counter.counts[nodeId] ?? 0) + 1,
},
};
}
function mergeGCounters(a: GCounter, b: GCounter): GCounter {
const allNodes = new Set([
...Object.keys(a.counts),
...Object.keys(b.counts),
]);
const merged: Record<string, number> = {};
for (const node of allNodes) {
merged[node] = Math.max(a.counts[node] ?? 0, b.counts[node] ?? 0);
}
return { counts: merged };
}
function getCount(counter: GCounter): number {
return Object.values(counter.counts).reduce((sum, c) => sum + c, 0);
}
OT vs CRDTs
| Aspect | Operational Transformation | CRDTs | |--------|---------------------------|-------| | Coordination | Requires central server | Peer-to-peer possible | | Consistency | Eventual (server-ordered) | Eventual (mathematically guaranteed) | | Complexity | Transform functions per operation type | Data structure design | | Offline support | Limited | Excellent | | Memory overhead | Low | Higher (metadata per element) | | Production examples | Google Docs | Figma, Linear, Notion |
Production Platforms
Building real-time infrastructure from scratch is a major undertaking. These platforms handle the hard parts, and our Supabase vs Firebase comparison can help you choose between them.
Supabase Realtime
Supabase extends PostgreSQL with real-time capabilities. Listen to database changes, broadcast messages, and track user presence — all through a single connection.
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Listen to database changes
const channel = supabase
.channel("db-changes")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages" },
(payload) => {
console.log("New message:", payload.new);
}
)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "messages" },
(payload) => {
console.log("Updated:", payload.new);
}
)
.subscribe();
// Presence tracking
const presenceChannel = supabase.channel("room-1");
presenceChannel
.on("presence", { event: "sync" }, () => {
const state = presenceChannel.presenceState();
console.log("Online users:", Object.keys(state).length);
})
.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await presenceChannel.track({
userId: "user-123",
username: "alice",
onlineAt: new Date().toISOString(),
});
}
});
Best for: apps already using Supabase/PostgreSQL, database-driven real-time features, presence tracking.
Firebase Realtime Database / Firestore
Firebase provides two real-time databases. Firestore is the newer, more scalable option with richer queries and offline support.
import { doc, onSnapshot, updateDoc, serverTimestamp } from "firebase/firestore";
import { db } from "@/lib/firebase";
const unsubscribe = onSnapshot(
doc(db, "documents", "doc-123"),
(snapshot) => {
if (snapshot.exists()) {
const data = snapshot.data();
console.log("Document updated:", data);
}
},
(error) => {
console.error("Listener error:", error);
}
);
async function updateDocument(docId: string, content: string) {
await updateDoc(doc(db, "documents", docId), {
content,
updatedAt: serverTimestamp(),
updatedBy: "user-123",
});
}
Best for: mobile-first apps, offline-capable apps, rapid prototyping, apps with complex real-time queries.
Liveblocks
Liveblocks is purpose-built for collaborative features: real-time presence, shared state, and conflict-free storage backed by CRDTs.
"use client";
import { useOthers, useMyPresence, useMutation, useStorage } from "@liveblocks/react";
export function CollaborativeEditor() {
const others = useOthers();
const [myPresence, updateMyPresence] = useMyPresence();
const items = useStorage((root) => root.items);
const addItem = useMutation(({ storage }, text: string) => {
const items = storage.get("items");
items.push({ id: crypto.randomUUID(), text, completed: false });
}, []);
const toggleItem = useMutation(({ storage }, itemId: string) => {
const items = storage.get("items");
const item = items.find((i) => i.get("id") === itemId);
if (item) item.set("completed", !item.get("completed"));
}, []);
return (
<div>
<div className="flex gap-2 mb-4">
{others.map(({ connectionId, presence }) => (
<div key={connectionId} className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm text-muted-foreground">
{(presence as { name?: string }).name}
</span>
</div>
))}
</div>
{items?.map((item) => (
<div key={item.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={item.completed}
onChange={() => toggleItem(item.id)}
/>
<span>{item.text}</span>
</div>
))}
</div>
);
}
Best for: collaborative SaaS features (cursors, selections, shared editing), multiplayer experiences, and products where presence awareness is a core feature.
Platform Comparison
| Feature | Supabase Realtime | Firebase Firestore | Liveblocks | |---------|------------------|-------------------|------------| | Data model | PostgreSQL rows | NoSQL documents | CRDT storage | | Real-time trigger | DB changes, broadcast | Document listeners | Storage mutations | | Presence | Built-in | Manual implementation | Built-in (first-class) | | Offline support | Limited | Excellent | Good | | Conflict resolution | Last-write-wins | Last-write-wins | CRDTs | | Pricing model | Per-connection | Per-read/write/delete | Per-MAU | | Self-hostable | Yes | No | No |
Scaling Real-Time Systems
Connection Management
Every open WebSocket or SSE connection consumes server resources. At scale, connection management becomes the primary engineering challenge.
// Connection limits and health monitoring
interface ConnectionPool {
maxConnections: number;
currentConnections: number;
connectionsByRoom: Map<string, number>;
}
function canAcceptConnection(
pool: ConnectionPool,
roomId: string,
maxPerRoom: number
): boolean {
if (pool.currentConnections >= pool.maxConnections) return false;
if ((pool.connectionsByRoom.get(roomId) ?? 0) >= maxPerRoom) return false;
return true;
}
Horizontal Scaling with Redis Pub/Sub
When you run multiple server instances behind a load balancer, each instance only knows about its own connections. Redis Pub/Sub synchronizes messages across instances.
import { createClient } from "redis";
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await pubClient.connect();
await subClient.connect();
await subClient.subscribe("room:updates", (message) => {
const data = JSON.parse(message);
broadcastToLocalClients(data.roomId, data.payload);
});
async function publishToRoom(roomId: string, payload: unknown) {
broadcastToLocalClients(roomId, payload);
await pubClient.publish(
"room:updates",
JSON.stringify({ roomId, payload })
);
}
Choosing the Right Architecture
| Use Case | Recommended Transport | Sync Model | Platform | |----------|----------------------|------------|----------| | Live dashboard | SSE | Server push | Custom or Supabase | | Chat / messaging | WebSockets | Event broadcast | Custom or Firebase | | Collaborative editing | WebSockets | CRDTs | Liveblocks or Yjs | | Notifications | SSE | Server push | Supabase or custom | | Real-time forms | WebSockets | Presence + state | Liveblocks | | Live sports scores | SSE | Server push | Custom | | Multiplayer game | WebSockets | State sync | Custom |
Getting Started
Real-time features delight users but add architectural complexity. The right choice depends on whether you need one-way updates or bidirectional sync, how you handle conflicts, and what scale you need to support.
If you are building real-time features into your application, talk to our team. We design and implement real-time architectures using Supabase, Liveblocks, and custom WebSocket infrastructure — built to scale reliably in production.
Build the experience your users expect. Make it real-time.
Explore Related Solutions
Need Help Building Your Project?
From web apps and mobile apps to AI solutions and SaaS platforms — we ship production software for 300+ clients.
Related Articles
API Security Best Practices: A Developer Guide for 2026
A practical guide to securing APIs in production. Covers OAuth 2.0, JWT handling, rate limiting, input validation, CORS configuration, API key management, and security headers with real code examples.
12 min readDatabase Scaling Strategies: From Single Server to Global Scale
A practical guide to database scaling strategies for growing applications. Covers vertical and horizontal scaling, read replicas, sharding, connection pooling, caching layers, and partition strategies with real SQL examples.
8 min readGraphQL vs REST API: When to Use Each in 2026
A practical comparison of GraphQL and REST for modern applications. Covers over-fetching, the N+1 problem, schema stitching, performance tradeoffs, and clear guidance on when each approach wins.