Skip to content
offline-first-architecture logo

Building Offline-First Applications

offline-first-architecture

Load when building offline-first applications that work without network connectivity. Applies when implementing local storage, sync queues, conflict resolution, or background sync.

telum-ai/speck0installs2stars

SKILL.md

Full skill instructions

Building Offline-First Applications

Follow these patterns for applications that work without network connectivity. Covers local-first data, sync strategies, conflict resolution, and background sync.

When This Rule Applies

Apply when building mobile apps, PWAs, or any application requiring reliable offline functionality.


Core Principles

  1. Local First: Read/​write to local storage, sync in background
  2. Optimistic UI: Show changes immediately, reconcile later
  3. Conflict Resolution: Define how to handle concurrent edits
  4. Sync Queue: Track pending changes, retry on reconnect

Local Storage Options

StorageSize LimitUse Case
IndexedDB~50% of diskStructured data, queries
localStorage~5-10MBSmall key-value
SQLite (native)UnlimitedMobile apps, large datasets
OPFSLargeFile storage in browser

IndexedDB with Dexie

import Dexie, { Table } from 'dexie';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
  updatedAt: number;
  syncStatus: 'synced' | 'pending' | 'conflict';
}

class AppDatabase extends Dexie {
  todos!: Table<Todo>;

  constructor() {
    super('AppDB');
    this.version(1).stores({
      todos: 'id, syncStatus, updatedAt',
    });
  }
}

const db = new AppDatabase();

// Offline-first write
async function updateTodo(id: string, updates: Partial<Todo>) {
  await db.todos.update(id, {
    ...updates,
    updatedAt: Date.now(),
    syncStatus: 'pending',
  });
  
  // Queue for sync
  await syncQueue.add({ type: 'update', entity: 'todo', id, updates });
}

Sync Queue Pattern

Queue Implementation

interface SyncOperation {
  id: string;
  type: 'create' | 'update' | 'delete';
  entity: string;
  entityId: string;
  data: any;
  createdAt: number;
  retryCount: number;
}

class SyncQueue {
  private db: Dexie;

  async add(operation: Omit<SyncOperation, 'id' | 'createdAt' | 'retryCount'>) {
    await this.db.table('syncQueue').add({
      ...operation,
      id: crypto.randomUUID(),
      createdAt: Date.now(),
      retryCount: 0,
    });
  }

  async process() {
    const operations = await this.db.table('syncQueue')
      .orderBy('createdAt')
      .toArray();

    for (const op of operations) {
      try {
        await this.executeOperation(op);
        await this.db.table('syncQueue').delete(op.id);
      } catch (error) {
        if (op.retryCount >= 3) {
          await this.handleFailure(op, error);
        } else {
          await this.db.table('syncQueue').update(op.id, {
            retryCount: op.retryCount + 1,
          });
        }
      }
    }
  }

  private async executeOperation(op: SyncOperation) {
    const response = await fetch(`/​api/​${op.entity}/​${op.entityId}`, {
      method: op.type === 'create' ? 'POST' : op.type === 'delete' ? 'DELETE' : 'PUT',
      body: JSON.stringify(op.data),
    });
    
    if (!response.ok) throw new Error(`Sync failed: ${response.status}`);
  }
}

Process on Reconnect

// Listen for online status
window.addEventListener('online', () => {
  syncQueue.process();
});

// Also process periodically
setInterval(() => {
  if (navigator.onLine) {
    syncQueue.process();
  }
}, 30000);

Conflict Resolution

Last-Write-Wins (Simple)

async function resolveConflict(local: Todo, server: Todo): Promise<Todo> {
  // Compare timestamps
  return local.updatedAt > server.updatedAt ? local : server;
}

Field-Level Merge

async function mergeConflict(local: Todo, server: Todo, base: Todo): Promise<Todo> {
  const merged = { ...server };
  
  // For each field, check if local changed it from base
  for (const key of Object.keys(local)) {
    const localChanged = local[key] !== base[key];
    const serverChanged = server[key] !== base[key];
    
    if (localChanged && !serverChanged) {
      // Local changed, server didn't → use local
      merged[key] = local[key];
    } else if (localChanged && serverChanged) {
      // Both changed → conflict, need resolution
      merged[key] = await resolveFieldConflict(key, local[key], server[key]);
    }
    // If only server changed or neither changed, keep server value
  }
  
  return merged;
}

User-Prompted Resolution

async function handleConflict(local: Todo, server: Todo): Promise<Todo> {
  // Store conflict for user review
  await db.conflicts.add({
    entity: 'todo',
    entityId: local.id,
    localVersion: local,
    serverVersion: server,
  });
  
  // Mark as conflict in UI
  await db.todos.update(local.id, { syncStatus: 'conflict' });
  
  // Show conflict resolution UI
  showConflictDialog(local.id);
  
  return server; // Use server as current until resolved
}

Background Sync API

// Register for background sync
async function requestSync() {
  if ('serviceWorker' in navigator && 'sync' in window.SyncManager) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-data');
  }
}

// Service worker: handle sync event
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(syncData());
  }
});

async function syncData() {
  const queue = await getQueuedOperations();
  
  for (const op of queue) {
    await executeOperation(op);
    await removeFromQueue(op.id);
  }
}

Optimistic UI Pattern

import { useMutation, useQueryClient } from '@tanstack/​react-query';

function useTodoMutation() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: updateTodo,
    
    onMutate: async (newTodo) => {
      // Cancel in-flight queries
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      
      // Snapshot previous
      const previous = queryClient.getQueryData(['todos']);
      
      // Optimistically update UI
      queryClient.setQueryData(['todos'], (old) =>
        old.map(t => t.id === newTodo.id ? newTodo : t)
      );
      
      // Store in local DB with pending status
      await db.todos.put({ ...newTodo, syncStatus: 'pending' });
      
      return { previous };
    },
    
    onError: (err, newTodo, context) => {
      // Rollback on error
      queryClient.setQueryData(['todos'], context.previous);
      await db.todos.update(newTodo.id, { syncStatus: 'error' });
    },
    
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Data Versioning

Vector Clocks (Distributed)

interface VectorClock {
  [nodeId: string]: number;
}

function incrementClock(clock: VectorClock, nodeId: string): VectorClock {
  return { ...clock, [nodeId]: (clock[nodeId] || 0) + 1 };
}

function compareClocks(a: VectorClock, b: VectorClock): 'before' | 'after' | 'concurrent' {
  let aBeforeB = false;
  let bBeforeA = false;
  
  const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
  
  for (const key of allKeys) {
    const aVal = a[key] || 0;
    const bVal = b[key] || 0;
    
    if (aVal < bVal) aBeforeB = true;
    if (bVal < aVal) bBeforeA = true;
  }
  
  if (aBeforeB && !bBeforeA) return 'before';
  if (bBeforeA && !aBeforeB) return 'after';
  return 'concurrent';
}

Common Gotchas

IndexedDB Transaction Limits

Transactions auto-commit after microtask. Keep transactions short.

Storage Quota

Check available storage, handle quota exceeded:

const estimate = await navigator.storage.estimate();
const usedPercent = (estimate.usage / estimate.quota) * 100;

Offline Detection is Unreliable

navigator.onLine can be wrong. Always handle network errors gracefully.

Clock Skew

Don't rely on client timestamps for ordering. Use server-assigned sequence numbers when possible.

Large Sync Payloads

Sync incrementally (since last sync timestamp) rather than full dataset.


Quick Reference

TaskPattern
Local storageIndexedDB with Dexie
Sync queueStore operations, process on reconnect
Conflict resolutionLast-write-wins or field-level merge
Optimistic UIUpdate immediately, rollback on error
Background syncService Worker Sync API
Version trackingupdatedAt or vector clocks

References

More skills from telum-ai

project-brainstorm logo
telum-ai/speck

project-brainstorm

Load when a user has a vague idea, problem, or inspiration but isn't sure what to build. Use before /project-specify when the project concept needs crystallizing from loose thoughts into something concrete.

2 0
View

Load when using Docker for containerizing applications. Applies when creating Dockerfiles, docker-compose setups, multi-stage builds, or optimizing container images.

2 0
View
visual-quality logo
telum-ai/speck

visual-quality

Enforce visual design quality in every UI file. Fires for .tsx, .jsx, .vue, .svelte, and similar component files. Ensures implementations express the project's design personality rather than producing generic/boilerplate UI.

2 0
View
project-clarify logo
telum-ai/speck

project-clarify

Load after project.md exists to fill gaps through targeted Q&A. Use when project goals, target users, or scope feel underspecified. Run before project-context or project-architecture to prevent rework downstream.

2 0
View
story-outline logo
telum-ai/speck

story-outline

DEPRECATED in Speck v7. Use /speck-skeptical-review + /speck-decision-log instead — they replace outline-style research mapping with structured alternative enumeration and decision locking. /story-plan now performs just-in-time research inline. This skill remains for v6 compatibility but redirect...

2 0
View
project-constitution logo
telum-ai/speck

project-constitution

Load (Platform play level only) to define technical principles and architectural guardrails for the project. Produces constitution.md — optional but valuable when a team needs explicit coding standards, governance rules, or non-negotiable constraints. FIRST ACTION after loading: read template at ...

2 0
View
posthog-integration logo
telum-ai/speck

posthog-integration

Load when integrating PostHog for product analytics and feature flags. Applies when implementing event tracking, A/B testing, session replay, or funnel analysis.

2 0
View
project-validate logo
telum-ai/speck

project-validate

Load when all epics are validated and /audit has run for the project. Verifies the project meets its original vision and product-contract.md paid promise. Runs end-to-end JTBD smoke test via /larp across all personas. Declares a project-level readiness state. Produces project-validation-report.md...

2 0
View
story-scan logo
telum-ai/speck

story-scan

Load for brownfield stories where existing code must be understood before planning. Use when a story extends, modifies, or integrates with significant existing functionality. Produces codebase-scan.md — skip for fully greenfield stories. FIRST ACTION after loading: read template at .speck/templat...

2 0
View

Load when building serverless applications with AWS Lambda, Vercel Functions, or Cloudflare Workers. Applies when implementing FaaS patterns, managing cold starts, or optimizing serverless costs.

2 0
View
epic-scan logo
telum-ai/speck

epic-scan

Load for brownfield epics to analyze existing code patterns and implementations relevant to this epic's domain before planning. Use when the epic modifies or extends significant existing functionality. Skip for fully greenfield epics. FIRST ACTION after loading: read template at .speck/templates/...

2 0
View
epic-analyze logo
telum-ai/speck

epic-analyze

Load after epic-tech-spec.md and epic-breakdown.md exist — quality check for consistency and completeness before any story work begins. Catches spec drift and missing coverage between the tech spec and story breakdown. FIRST ACTION after loading: read template at .speck/templates/epic/epic-analys...

2 0
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View