Real-Time Data Sync Architectures: WebSockets, SSE, CRDTs, and Beyond
Author
Date Published
TL;DR: A deep dive into real-time data synchronization architectures. Covers WebSockets, Server-Sent Events, CRDTs, and operational transforms with practical guidance on Supabase Realtime, Firebase, and Liveblocks.
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 })
);
}
Latency and Cost Budgets per Use Case
Real-time is not a single target — different features have different acceptable latency floors and different cost ceilings. Set these budgets explicitly before picking a transport.
| Use case | Target p95 latency | Acceptable message drop | Realistic infra cost (10K DAU) | |----------|---------------------|-------------------------|---------------------------------| | Live cursors / drawing | < 50ms | 0 | $200-$600/mo (Liveblocks or self-hosted Yjs + CRDT) | | Collaborative text editing | < 100ms | 0 | $300-$1,200/mo (Liveblocks, Tiptap Cloud, or Yjs + y-websocket) | | Chat / messaging | < 200ms | < 0.1% | $100-$400/mo (Ably, Pusher, or custom WebSocket + Redis) | | Live dashboards / feeds | < 500ms | < 1% | $50-$200/mo (SSE + serverless) | | Notifications | < 2s | < 1% | $20-$100/mo (SSE or push notifications) | | Presence (online/offline) | < 5s | < 5% | $20-$80/mo (heartbeat + Redis TTL) |
Platform Pricing Snapshot (Dec 2024 - 2025 pricing pages)
| Platform | Pricing model | Entry tier | At 10K MAU | |----------|----------------|-------------|-------------| | Ably | Per message + connection minutes | Free up to 3M msgs/mo | ~$200-500/mo | | Pusher Channels | Per connection + message | Free up to 100 connections | ~$100-400/mo | | Liveblocks | Per MAU | Free up to 50 MAU | ~$500-2,000/mo | | Supabase Realtime | Included in Supabase Pro plan | $25/mo Pro tier base | ~$50-200/mo (connections-bounded) | | PartyKit | Open-source, Cloudflare-hosted | Free tier generous | ~$50-200/mo | | Custom (Node.js + Redis on Fly.io) | Infra only | $20-50/mo baseline | ~$100-300/mo |
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.
Frequently Asked Questions
What latency should I target for "real-time" in a business app?
Under 200ms end-to-end is imperceptible for most collaborative UIs. Under 50ms is required for cursor sharing, live drawing, and gaming. Business dashboards and chat tolerate 500-1000ms fine. Pick your target before picking a technology — WebSockets, SSE, and polling each land in different latency buckets.
WebSockets vs Server-Sent Events vs long polling — when does each win?
WebSockets win for bidirectional real-time (chat, collaborative editing). SSE wins for server-to-client only (live feeds, notifications) and is simpler to operate since it's plain HTTP. Long polling remains the fallback when your infrastructure can't hold connections open. Most teams over-reach for WebSockets when SSE would do.
How many concurrent WebSocket connections can a single server handle?
A tuned Node.js or Go server holds 50K-100K idle WebSocket connections per instance on modern hardware. Message throughput drops that number fast — heavy broadcast workloads often cap at 10-20K connections per node. Plan horizontal scaling with a pub/sub layer (Redis, NATS, Kafka) once you cross 5K active connections.
What's the classic failure mode in real-time systems?
Thundering herd on reconnect. After a deploy or network blip, thousands of clients reconnect simultaneously and crush your API. Fix with exponential backoff plus jitter on the client and connection rate limiting at the gateway. Every team learns this one the hard way in its first outage.
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
Web Performance Optimization in 2026: A Complete Guide to Core Web Vitals
A hands-on guide to web performance optimization in 2026. Covers Core Web Vitals (LCP, CLS, INP), image optimization, code splitting, font loading, and measurable strategies to ship faster sites.
16 min readTypeScript Design Patterns: A Practical Guide for 2026
A hands-on guide to implementing design patterns in TypeScript. Covers factory, observer, strategy, decorator, and builder patterns with real-world code examples and guidance on when each pattern solves a genuine problem.
14 min readTesting Strategies for Modern Web Apps: Vitest, Playwright, and Beyond
A practical guide to testing modern web applications. Covers unit, integration, and E2E testing with Vitest, Playwright, and Testing Library. Includes the test pyramid, coverage strategies, and CI integration.