Resilience Is the New UX: Building Offline-First Real-Time Apps That Survive Network Failure
Originally published on tamiz.pro.
The era of assuming a stable network is over. Users move through tunnels, elevators, and spotty mobile coverage, yet they expect real-time collaboration to feel instant. The gap between optimistic UI updates and server truth is where UX dies.
Offline-first is not a feature; it's an architectural stance that treats network failure as a normal state, not an exception. This deep-dive explores the core principles, data structures, and sync strategies you need to build real-time apps that don't fall apart when the network does.
Table of Contents
- The Offline-First Mindset
- Core Principles: Local-First, Sync-Later
- Data Layer: Local Storage and IndexedDB
- Sync Engines: The Heart of Offline-First
- Conflict Resolution with CRDTs
- Real-Time Transport: WebSockets and Fallbacks
- Practical Implementation: A Collaborative Editor Skeleton
- Testing Network Resilience
- Production Considerations and Trade-offs
Frequently Asked Questions
1. The Offline-First Mindset
Traditional real-time apps (like chat or collaborative tools) rely on a persistent connection to a server. When the network blips, the UI freezes, the user loses context, and the app becomes useless.
Offline-first flips this: the client is the source of truth for user interactions. All writes go to a local store first, then sync to the server when connectivity allows. This is the same philosophy behind local-first software -an approach that prioritizes user control and resilience.
The key shift is from server-authoritative to client-authoritative with eventual consistency . Users see their changes immediately (optimistic updates), and the system reconciles differences in the background. This requires a robust data model, a sync protocol, and conflict resolution-not just a cache.
2. Core Principles: Local-First, Sync-Later
Implementing offline-first means adhering to a few non-negotiable principles:
- Local writes are immediate: Never block the UI on a network round-trip. Write to local storage (IndexedDB, SQLite, etc.) synchronously or with minimal latency.
- Sync is asynchronous and retryable: The sync engine should handle intermittent connectivity, exponential backoff, and partial failures.
- Conflicts are expected: Multiple devices or users can modify the same data while offline. The system must merge changes deterministically.
- Network is a transport, not a dependency: WebSockets, HTTP, or any transport can be used, but the app must function without them.
This mindset influences every layer: from the data model (must support versioning) to the UI (must show sync status).
3. Data Layer: Local Storage and IndexedDB
For web apps, IndexedDB is the de facto standard for structured local storage. It's asynchronous, supports transactions, and can store large amounts of data. But raw IndexedDB is verbose. Libraries like Dexie.js and idb provide a friendlier API.
A critical design decision: store both data and metadata (revision, timestamp, device ID, etc.) to support sync.
Here's a typical schema for a collaborative note:
// Schema for a note in IndexedDB
interface Note {
id : string ; // UUID
title : string ;
content : string ;
updatedAt : number ; // server timestamp (or local)
revision : number ; // monotonic revision number
deviceId : string ; // originating device
deleted : boolean ; // tombstone for deletes
}
When a user edits, you update the local record and increment the revision. The sync engine later sends the latest revision to the server.
4. Sync Engines: The Heart of Offline-First
The sync engine is responsible for:
- Tracking changes : Maintain a queue of pending mutations (create, update, delete).
- Sending changes : Push local changes to the server.
- Receiving changes : Pull remote changes and apply them.
- Resolving conflicts : Merge conflicting changes.
A common pattern is the outbox pattern: every write goes to a local outbox table firstcars. The sync engine processes the outbox in order, sending mutations to the server. On success, it clears the outbox entry. On failure, it retries with backoff.
Here's a simplified sync engine in TypeScript:
class SyncEngine {
private outbox : Mutation [] = [];
private isSyncing = false ;
async enqueue ( mutation : Mutation ) {
this . outbox . push ( mutation );
await this . persistOutbox ();
this . sync ();
}
async sync () {
if ( this . isSyncing ) return ;
this . isSyncing = true ;
while ( this . outbox . length > 0 ) {
const mutation = this . outbox [ 0 ];
try {
await this . sendMutation ( mutation );
this . outbox . shift ();
await this . persistOutbox ();
} catch ( e ) {
// Network error: backoff and retry later
await this . backoff ();
break ;
}
}
this . isSyncing = false ;
}
}
For pulling changes, the server maintains a change log (or you use a sync protocol like Yjs or Automerge ). The client periodically asks for changes since the last sync point ( since timestamp or revision).
5. Conflict Resolution with CRDTs
Conflict resolution is the hardest part. Two users editing the same field offline will produce divergent states. You have several options:
- Last-write-wins (LWW) : Simple, but loses data. Use with timestamps, but clocks can be unreliable.
- Field-level merging : Merge different fields, but conflicts on the same field still need resolution.
- CRDTs (Conflict-free Replicated Data Types) : Data structures that mathematically guarantee convergence without coordination.
For text, Yjs uses a CRDT that allows concurrent edits to merge seamlessly. Yjs is the de facto standard for collaborative text editing. It uses a binary encoding and a sophisticated algorithm that preserves user intent. For example, two users typing in the same paragraph offline will both see their changes merged when they reconnect.
For application state (like a JSON object), you can use Automerge or Yjs' Y.Map . These are more complex but provide true conflict-free merging.
Here's how to use Yjs with a WebSocket provider:
import * as Y from ' yjs ' ;
import { WebsocketProvider } from ' y-websocket ' ;
const doc = new Y . Doc ();
const provider = new WebsocketProvider ( ' ws://localhost:1234 ' , ' my-room ' , doc );
provider . on ( ' status ' , ( event ) => {
console . log ( ' Sync status: ' , event . status ); // 'connected' or 'disconnected'
});
const ytext = doc . getText ( ' content ' );
ytext . insert ( 0 , ' Hello, offline world! ' );
Yjs handles local persistence via y-indexeddb , so you can reload the page and still have your changes.
6. Real-Time Transport: WebSockets and Fallbacks
WebSockets are great for low-latency real-time communication, but they can drop. For offline-first, you need a transport that can recover. Use WebSockets as the primary channel, but have a fallback to HTTP long-polling or SSE (Server-Sent Events) for when WebSockets fail (e.g., behind some proxies).
Libraries like Socket.IO provide automatic fallback to polling and reconnection. However, for offline-first, the sync engine should be independent of the transport. The transport is just a pipe for mutations and updates.
When the connection is lost, the client should buffer events locally and replay them on reconnect. This is similar to how chat apps queue messages.
7. Practical Implementation: A Collaborative Editor Skeleton
Let's build a minimal offline-first collaborative note app using Yjs, IndexedDB, and a Node.js server. We'll use y-websocket for sync and y-indexeddb for persistence.
Prerequisites
- Node.js 18+
- npm
- Basic knowledge of TypeScript and Express
Step 1: Set up the project
mkdir offline-first-editor
cd offline-first-editor
npm init -y
npm install yjs y-websocket y-indexeddb express ws
Step 2: Create the server (server.js)
const express = require ( ' express ' );
const { WebSocketServer } = require ( ' ws ' );
const { setupWSConnection } = require ( ' y-websocket/bin/utils ' );
const app = express ();
const server = app . listen ( 1234 );
const wss = new WebSocketServer ({ server });
wss . on ( ' connection ' , ( conn , req ) => {
setupWSConnection ( conn , req );
});
console . log ( ' Server running on ws://localhost:1234 ' );
Step 3: Create the client (index.html and client.js)
In index.html , include a contenteditable div and the client script.
<!DOCTYPE html>
<html>
<head>
<title> Offline-First Editor </title>
</head>
<body>
<div id= "editor" contenteditable= "true" style= "border:1px solid #ccc; height:300px;" ></div>
<div id= "status" > Offline </div>
<script src= "client.js" type= "module" ></script>
</body>
</html>
In client.js :
import * as Y from ' yjs ' ;
import { WebsocketProvider } from ' y-websocket ' ;
import { IndexeddbPersistence } from ' y-indexeddb ' ;
const doc = new Y . Doc ();
const provider = new WebsocketProvider ( ' ws://localhost:1234 ' , ' notes ' , doc );
const persistence = new IndexeddbPersistence ( ' notes ' , doc );
// Sync status
provider . on ( ' status ' , ( e ) => {
document . getElementById ( ' status ' ). textContent = e . status === ' connected ' ? ' Online ' : ' Offline ' ;
});
// Bind the Y.Text to the contenteditable div
const ytext = doc . getText ( ' content ' );
const editor = document . getElementById ( ' editor ' );
const render = () => {
editor . innerText = ytext . toString ();
};
ytext . observe ( render );
render ();
// Handle user input
editor . addEventListener ( ' input ' , () => {
const text = editor . innerText ;
if ( text !== ytext . toString ()) {
// Replace the entire text (simplified; for production use y-prosemirror or y-quill)
ytext . delete ( 0 , ytext . length );
ytext . insert ( 0 , text );
}
});
Run the server with node server.js and open index.html . Try editing, then turn off Wi-Fi, keep editing, and reconnect. Your changes sync seamlessly.
8. Testing Network Resilience
Testing offline behavior is crucial. Use tools like Chrome DevTools' Network Throttling to simulate offline and high-latency conditions.
For automated tests, consider:
- Jest with fake timers to test backoff logic.
- Cypress with network stubbing to simulate disconnects.
- Puppeteer to emulate offline mode.
A simple test for the sync engine:
// sync.test.ts
import { SyncEngine } from ' ./sync ' ;
it ( ' retries on failure ' , async () => {
const engine = new SyncEngine ();
let attempts = 0 ;
engine . sendMutation = async () => {
attempts ++ ;
if ( attempts < 3 ) throw new Error ( ' Network down ' );
};
await engine . enqueue ({ type : ' update ' , id : ' 1 ' , data : {} });
expect ( attempts ). toBe ( 3 );
});
9. Production Considerations and Trade-offs
Offline-first is powerful but not free. Consider these trade-offs:
- Complexity : Conflict resolution and sync logic add significant complexity.
- Data consistency : Eventual consistency means users might see slightly different states temporarily.
- Storage limits : IndexedDB has quotas; large data sets need careful management.
- Security : Offline data is on the device; encrypt sensitive data at rest.
When to use offline-first:
- Collaborative apps (editors, whiteboards)
- Field work apps (inventory, inspections)
- Messaging apps (already do this)
When not to use:
- Banking transactions (require strong consistency)
- Real-time dashboards (data is ephemeral)
Frequently Asked Questions
Q: Can I use CRDTs for non-text data like forms?
Yes. Yjs provides Y.Map and Y.Array for structured data. Automerge is another option that works on JSON-like data. For simple fields, LWW with server timestamps might suffice.
Q: How do I handle file uploads offline?
Queue the file metadata and upload the blob when online. Use IndexedDB to store the blob, then sync with a dedicated upload queue. Ensure you handle partial uploads with resumable upload protocols.
Q: What if two users delete the same item offline?
CRDTs handle this with tombstones. Both deletes are merged, and the item is deleted once. For LWW, the last delete wins.
Building offline-first real-time apps is a shift in mindset. By treating the network as a fallible transport and the client as a resilient store, you create apps that respect the user's time and context. Start small-add local persistence to one feature, then layer sync. The investment pays off in user trust and engagement.
For more insights on building resilient systems, check out Tamiz's Insights .
Comments
No comments yet. Start the discussion.