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!
Building applications where multiple people can work together at the same time is both thrilling and tricky. I remember the first time I tried to make a simple collaborative checklist. Two users checked off an item simultaneously, and suddenly it was unchecked for one of them. The data became a confusing mess. This experience sent me on a journey to understand how to keep data in sync across many clients, especially when they all try to change things at once.
The core problem is concurrency. When two users edit the same field while offline, or their network messages cross paths, who wins? A naive approach is to just take the last update received by the server. This often leads to a poor experience where someone's work seems to vanish. We need smarter ways to detect these clashes and resolve them fairly.
One foundational method is to track the cause and effect of changes. We can use logical clocks to understand the relationship between operations. Think of it like giving every change a unique passport stamp that shows its origin and sequence.
Here’s a basic structure for a system that can detect when operations might be in conflict.
class LogicalClock {
constructor(clientId) {
this.clientId = clientId;
this.counter = 0;
}
tick() {
this.counter++;
return this.getStamp();
}
getStamp() {
return { clientId: this.clientId, counter: this.counter };
}
// When receiving another clock, we sync to the highest known counter for each client
observe(otherStamp) {
// Implementation for merging logical timestamps
}
}
When a client makes a change, it tags that operation with its current logical clock stamp. The server and other clients can compare these stamps. If one operation's stamp clearly comes after another, we know the order. If the stamps are from different clients and neither clearly precedes the other, we have a concurrent edit—a conflict.
Once we know a conflict exists, we need to resolve it. There are several common strategies. "Last Write Wins" is simple but often discards data. "First Write Wins" can feel arbitrary. More sophisticated applications might use application-specific merging.
For example, if two users are editing different parts of a JSON object, we can merge those changes safely.
function mergeJSONChanges(localChange, remoteChange) {
// A simple deep merge for non-conflicting paths
const merged = { ...localChange };
for (const key in remoteChange) {
if (!(key in merged)) {
merged[key] = remoteChange[key];
} else if (typeof merged[key] === 'object' && typeof remoteChange[key] === 'object') {
// Recurse for nested objects
merged[key] = mergeJSONChanges(merged[key], remoteChange[key]);
} else {
// Conflict on a primitive value (e.g., both changed 'title')
// Here we need a rule. Maybe pick the one with the later timestamp.
merged[key] = resolvePrimitiveConflict(localChange[key], remoteChange[key]);
}
}
return merged;
}
A powerful technique for text collaboration, used in tools like Google Docs, is Operational Transformation (OT). The idea isn't just to merge the final state, but to transform the operations themselves as they flow between clients. If I type "cat" at the beginning of a document while you delete the first letter, OT intelligently adjusts my insert position so my "cat" doesn't end up in the wrong place.
Building a basic OT system for a text area teaches you a lot about collaboration.
class SimpleTextTransformer {
constructor() {
this.operations = [];
}
// An operation could be: { type: 'insert', pos: 5, text: 'hello' }
applyOperation(op) {
// We must transform this new operation against all previously applied ones
let transformedOp = op;
for (const pastOp of this.operations) {
transformedOp = this.transform(transformedOp, pastOp);
}
// Apply the transformed operation to the document
this.operations.push(transformedOp);
return transformedOp;
}
transform(newOp, pastOp) {
// This is the core logic. How does 'insert at position 10' change
// if a previous operation 'inserted at position 5'?
if (pastOp.type === 'insert' && newOp.type === 'insert') {
if (newOp.pos >= pastOp.pos) {
return { ...newOp, pos: newOp.pos + pastOp.text.length };
}
}
if (pastOp.type === 'delete' && newOp.type === 'insert') {
// Adjust insert position if text before it was deleted
if (newOp.pos > pastOp.pos) {
return { ...newOp, pos: newOp.pos - pastOp.length };
}
}
// ... handle all combinations: insert/delete, delete/insert, delete/delete
return newOp;
}
}
While OT is great for ordered sequences like text, another brilliant concept exists for other data types: Conflict-Free Replicated Data Types (CRDTs). A CRDT is a data structure designed so that any two copies can be merged automatically, without conflict, and guarantee they will end up in the same state. They feel like magic the first time you use one.
A classic example is a counter that multiple people can increment. A naive counter would lose counts if two increments happen at the same time. A CRDT counter, however, tracks increments per client.
class GrowOnlyCounterCRDT {
constructor(clientId) {
this.state = new Map(); // clientId -> count
this.clientId = clientId;
this.state.set(clientId, 0);
}
increment() {
const current = this.state.get(this.clientId) || 0;
this.state.set(this.clientId, current + 1);
}
get value() {
let sum = 0;
for (const count of this.state.values()) {
sum += count;
}
return sum;
}
// The magic merge function
merge(otherCounterState) {
for (const [clientId, otherCount] of otherCounterState) {
const myCount = this.state.get(clientId) || 0;
this.state.set(clientId, Math.max(myCount, otherCount));
}
}
}
// Client A increments 3 times. State: { A:3 }
// Client B increments 5 times. State: { B:5 }
// When they merge: { A:3, B:5 } -> Total is 8. No increments are lost.
For real-time updates, the choice of network transport is key. WebSockets provide a full-duplex, persistent connection, perfect for constant chatter between client and server. Setting up a basic sync service over WebSocket involves more than just sending JSON.
class SyncWebSocketClient {
constructor(url) {
this.ws = new WebSocket(url);
this.pendingOperations = new Map();
this.nextOperationId = 0;
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleServerMessage(message);
};
this.ws.onopen = () => {
console.log("Connected. Flushing pending ops...");
this.flushPendingOperations();
};
}
// Queue operations if offline, send immediately if online
submitOperation(op) {
const opWithId = { ...op, id: `op_${this.nextOperationId++}`, clientId: this.myId };
if (this.ws.readyState !== WebSocket.OPEN) {
this.pendingOperations.set(opWithId.id, opWithId);
console.log("Offline: operation queued.");
} else {
this.ws.send(JSON.stringify({ type: 'operation', data: opWithId }));
}
}
flushPendingOperations() {
for (const op of this.pendingOperations.values()) {
this.ws.send(JSON.stringify({ type: 'operation', data: op }));
}
this.pendingOperations.clear();
}
handleServerMessage(msg) {
if (msg.type === 'operation_broadcast') {
// Apply a transformed operation from another client
this.applyRemoteOperation(msg.data);
}
if (msg.type === 'operation_ack') {
// Server acknowledged our operation, remove from pending
this.pendingOperations.delete(msg.data.id);
}
if (msg.type === 'state_snapshot') {
// Full state sync, useful after reconnection
this.replaceState(msg.data);
}
}
}
Handling offline scenarios is non-negotiable for a robust app. This means storing changes locally and syncing them later. IndexedDB or even localStorage can serve as this offline queue.
class OfflineQueue {
constructor(storageKey = 'offline_ops') {
this.storageKey = storageKey;
this.ops = this.loadFromStorage();
}
enqueue(operation) {
this.ops.push({
...operation,
timestamp: Date.now(),
synced: false
});
this.saveToStorage();
}
markAsSynced(operationId) {
const op = this.ops.find(o => o.id === operationId);
if (op) op.synced = true;
this.saveToStorage();
}
getUnsyncedOperations() {
return this.ops.filter(op => !op.synced);
}
clearSynced() {
this.ops = this.ops.filter(op => !op.synced);
this.saveToStorage();
}
loadFromStorage() {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : [];
}
saveToStorage() {
localStorage.setItem(this.storageKey, JSON.stringify(this.ops));
}
}
Testing these systems is its own challenge. You must simulate terrible network conditions: high latency, dropped messages, and clients going offline at the worst possible time. I often write simulation scripts that run headless clients and orchestrate chaos.
async function testConflictScenario() {
const clientA = new TestClient('A');
const clientB = new TestClient('B');
const server = new TestServer();
// Both start with same document: "Hello world"
await server.connect(clientA);
await server.connect(clientB);
// Network partition - A and B cannot see each other's updates
server.partition([clientA], [clientB]);
// A edits: "Hello awesome world"
clientA.edit(6, 0, 'awesome '); // Insert 'awesome ' at position 6
// B edits: "Hello big world"
clientB.edit(6, 0, 'big '); // Insert 'big ' at position 6
// Heal the partition
server.healPartition();
// Now sync. What is the final document?
await server.syncAll();
const finalState = server.getDocument();
console.log('Final document:', finalState);
// Possible outcomes:
// "Hello awesome big world" (merged)
// "Hello big awesome world" (merged, different order)
// "Hello awesome world" or "Hello big world" (one wins)
// The test validates the chosen strategy works as intended.
}
Finally, integrating all this into a modern UI framework like React requires careful thought. You want a hook that feels like useState but is synchronized and conflict-aware.
function useSyncedState(path, initialValue) {
const [state, setInternalState] = useState(initialValue);
const syncEngine = useContext(SyncEngineContext);
useEffect(() => {
// Subscribe to changes for this specific path
const unsubscribe = syncEngine.subscribe(path, (newValue) => {
setInternalState(newValue);
});
// Request current value
syncEngine.getValue(path).then(setInternalState);
return unsubscribe;
}, [path, syncEngine]);
const setState = useCallback((newValue) => {
// This doesn't just set local state; it creates and sends an operation
const operation = {
type: 'SET',
path: path,
value: typeof newValue === 'function' ? newValue(state) : newValue
};
syncEngine.submitOperation(operation);
// Local update is optimistic; will be confirmed or adjusted by sync engine
setInternalState(operation.value);
}, [path, state, syncEngine]);
return [state, setState];
}
// In a component, it looks simple:
function DocumentTitle() {
const [title, setTitle] = useSyncedState('document.title', 'Untitled');
return <input value={title} onChange={e => setTitle(e.target.value)} />;
}
Each of these techniques offers a different balance between complexity, correctness, and performance. For a todo list, a simple timestamp-based merge might be enough. For a collaborative code editor, you'll likely lean on Operational Transformation. For a shared counter or a set of unique tags, a CRDT is elegant.
The journey from that broken checklist to building reliable collaborative tools taught me that there is no single perfect solution. It's about understanding the trade-offs and choosing the right combination of strategies for your specific application's needs. The goal is to make the complexity invisible to the user, so they can work together seamlessly, as if by magic, even when the network is anything but perfect.
📘 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)