DEV Community

Tamiz Uddin
Tamiz Uddin

Posted on Originally published at tamiz.pro

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

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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!');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)