Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete)....
npx skills add Cap-go/capacitor-skills --skill "capacitor-offline-first"
Install specific skill from multi-skill repository
# Description
Guide to building offline-first Capacitor apps with data synchronization, caching strategies, and conflict resolution. Covers SQLite, service workers, and network detection. Use this skill when users need their app to work without internet.
# SKILL.md
name: capacitor-offline-first
description: Guide to building offline-first Capacitor apps with data synchronization, caching strategies, and conflict resolution. Covers SQLite, service workers, and network detection. Use this skill when users need their app to work without internet.
Offline-First Capacitor Apps
Build apps that work seamlessly with or without internet connectivity.
When to Use This Skill
- User needs offline support
- User asks about data sync
- User wants caching
- User needs local database
- User has connectivity issues
Offline-First Architecture
βββββββββββββββββββββββββββββββββββββββββββ
β UI Layer β
βββββββββββββββββββββββββββββββββββββββββββ€
β Service Layer β
β βββββββββββββββ βββββββββββββββββββ β
β β Online Mode β β Offline Mode β β
β ββββββββ¬βββββββ ββββββββββ¬βββββββββ β
βββββββββββΌβββββββββββββββββββΌβββββββββββββ€
β β Sync Manager β β
β ββββββββββ¬ββββββββββ β
ββββββββββββββββββββΌβββββββββββββββββββββββ€
β βββββββββββββββββ΄ββββββββββββββββββββ β
β β Local Database β β
β β (SQLite / IndexedDB) β β
β βββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββ
Network Detection
Using Capacitor Network Plugin
bun add @capacitor/network
bunx cap sync
import { Network } from '@capacitor/network';
// Check current status
const status = await Network.getStatus();
console.log('Connected:', status.connected);
console.log('Connection type:', status.connectionType);
// Listen for changes
Network.addListener('networkStatusChange', (status) => {
console.log('Network status changed:', status.connected);
if (status.connected) {
// Back online - sync data
syncManager.syncPendingChanges();
} else {
// Offline - show indicator
showOfflineIndicator();
}
});
Network-Aware Service
import { Network } from '@capacitor/network';
class NetworkAwareService {
private isOnline = true;
constructor() {
this.init();
}
private async init() {
const status = await Network.getStatus();
this.isOnline = status.connected;
Network.addListener('networkStatusChange', (status) => {
this.isOnline = status.connected;
});
}
async fetch<T>(url: string, options?: RequestInit): Promise<T> {
if (!this.isOnline) {
// Return cached data
return this.getCachedData(url);
}
try {
const response = await fetch(url, options);
const data = await response.json();
// Cache the response
await this.cacheData(url, data);
return data;
} catch (error) {
// Network error - try cache
return this.getCachedData(url);
}
}
}
Local Database with SQLite
Installation
bun add @capgo/capacitor-data-storage-sqlite
bunx cap sync
Database Setup
import { CapacitorDataStorageSqlite } from '@capgo/capacitor-data-storage-sqlite';
class Database {
private db = CapacitorDataStorageSqlite;
private isOpen = false;
async open() {
if (this.isOpen) return;
await this.db.openStore({
database: 'myapp',
table: 'data',
encrypted: false,
mode: 'no-encryption',
});
this.isOpen = true;
}
async set(key: string, value: any) {
await this.open();
await this.db.set({
key,
value: JSON.stringify(value),
});
}
async get<T>(key: string): Promise<T | null> {
await this.open();
const result = await this.db.get({ key });
return result.value ? JSON.parse(result.value) : null;
}
async remove(key: string) {
await this.open();
await this.db.remove({ key });
}
async keys(): Promise<string[]> {
await this.open();
const result = await this.db.keys();
return result.keys;
}
}
Offline Data Repository
interface Entity {
id: string;
updatedAt: number;
syncStatus: 'synced' | 'pending' | 'conflict';
}
class OfflineRepository<T extends Entity> {
constructor(
private db: Database,
private collection: string
) {}
async getAll(): Promise<T[]> {
const keys = await this.db.keys();
const items: T[] = [];
for (const key of keys) {
if (key.startsWith(`${this.collection}:`)) {
const item = await this.db.get<T>(key);
if (item) items.push(item);
}
}
return items;
}
async getById(id: string): Promise<T | null> {
return this.db.get<T>(`${this.collection}:${id}`);
}
async save(item: T): Promise<void> {
item.updatedAt = Date.now();
item.syncStatus = 'pending';
await this.db.set(`${this.collection}:${item.id}`, item);
}
async delete(id: string): Promise<void> {
// Soft delete - mark for sync
const item = await this.getById(id);
if (item) {
item.syncStatus = 'pending';
(item as any).deleted = true;
await this.db.set(`${this.collection}:${id}`, item);
}
}
async getPending(): Promise<T[]> {
const all = await this.getAll();
return all.filter((item) => item.syncStatus === 'pending');
}
async markSynced(id: string): Promise<void> {
const item = await this.getById(id);
if (item) {
item.syncStatus = 'synced';
await this.db.set(`${this.collection}:${id}`, item);
}
}
}
Sync Manager
import { Network } from '@capacitor/network';
class SyncManager {
private isSyncing = false;
private syncQueue: Array<() => Promise<void>> = [];
constructor(private repositories: OfflineRepository<any>[]) {
this.setupNetworkListener();
}
private setupNetworkListener() {
Network.addListener('networkStatusChange', async (status) => {
if (status.connected) {
await this.syncAll();
}
});
}
async syncAll() {
if (this.isSyncing) return;
this.isSyncing = true;
try {
for (const repo of this.repositories) {
await this.syncRepository(repo);
}
} finally {
this.isSyncing = false;
}
}
private async syncRepository(repo: OfflineRepository<any>) {
const pending = await repo.getPending();
for (const item of pending) {
try {
if ((item as any).deleted) {
await this.deleteRemote(item);
} else {
await this.syncToRemote(item);
}
await repo.markSynced(item.id);
} catch (error) {
console.error('Sync failed for item:', item.id, error);
// Keep as pending for retry
}
}
// Pull remote changes
await this.pullRemoteChanges(repo);
}
private async syncToRemote(item: any) {
await fetch(`/api/${item.collection}/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
}
private async deleteRemote(item: any) {
await fetch(`/api/${item.collection}/${item.id}`, {
method: 'DELETE',
});
}
private async pullRemoteChanges(repo: OfflineRepository<any>) {
const lastSync = await this.getLastSyncTime(repo);
const response = await fetch(
`/api/${repo.collection}?since=${lastSync}`
);
const remoteItems = await response.json();
for (const remoteItem of remoteItems) {
const localItem = await repo.getById(remoteItem.id);
if (!localItem) {
// New item from server
await repo.save({ ...remoteItem, syncStatus: 'synced' });
} else if (localItem.syncStatus === 'synced') {
// No local changes - update from server
await repo.save({ ...remoteItem, syncStatus: 'synced' });
} else {
// Conflict - local has pending changes
await this.resolveConflict(localItem, remoteItem, repo);
}
}
await this.setLastSyncTime(repo, Date.now());
}
private async resolveConflict(
local: any,
remote: any,
repo: OfflineRepository<any>
) {
// Last-write-wins strategy
if (local.updatedAt > remote.updatedAt) {
// Keep local, re-sync to server
local.syncStatus = 'pending';
await repo.save(local);
} else {
// Server wins
await repo.save({ ...remote, syncStatus: 'synced' });
}
}
}
Service Worker Caching
Register Service Worker
// src/main.ts
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
Service Worker with Workbox
// public/sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);
// Cache API responses
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
})
);
// Cache images
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
{
expiration: {
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
},
},
],
})
);
// Cache fonts
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'font-cache',
})
);
Optimistic UI Updates
class TodoService {
constructor(
private repo: OfflineRepository<Todo>,
private syncManager: SyncManager
) {}
async addTodo(text: string): Promise<Todo> {
const todo: Todo = {
id: crypto.randomUUID(),
text,
completed: false,
updatedAt: Date.now(),
syncStatus: 'pending',
};
// Save locally immediately
await this.repo.save(todo);
// Trigger sync in background
this.syncManager.syncAll().catch(console.error);
return todo;
}
async toggleComplete(id: string): Promise<Todo> {
const todo = await this.repo.getById(id);
if (!todo) throw new Error('Todo not found');
todo.completed = !todo.completed;
await this.repo.save(todo);
this.syncManager.syncAll().catch(console.error);
return todo;
}
}
Queue Failed Requests
class RequestQueue {
private queue: QueuedRequest[] = [];
constructor(private storage: Database) {
this.loadQueue();
}
private async loadQueue() {
this.queue = await this.storage.get<QueuedRequest[]>('requestQueue') || [];
}
private async saveQueue() {
await this.storage.set('requestQueue', this.queue);
}
async enqueue(request: QueuedRequest) {
this.queue.push(request);
await this.saveQueue();
}
async processQueue() {
const status = await Network.getStatus();
if (!status.connected) return;
while (this.queue.length > 0) {
const request = this.queue[0];
try {
await fetch(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
});
this.queue.shift();
await this.saveQueue();
} catch (error) {
// Stop processing on failure
break;
}
}
}
}
Best Practices
1. Show Sync Status
function SyncIndicator() {
const { isOnline, pendingChanges, isSyncing } = useSyncStatus();
if (!isOnline) {
return <Badge color="warning">Offline</Badge>;
}
if (isSyncing) {
return <Badge color="info">Syncing...</Badge>;
}
if (pendingChanges > 0) {
return <Badge color="warning">{pendingChanges} pending</Badge>;
}
return <Badge color="success">Synced</Badge>;
}
2. Handle Conflicts Gracefully
async function handleConflict(local: Todo, remote: Todo): Promise<Todo> {
// Option 1: Last write wins
return local.updatedAt > remote.updatedAt ? local : remote;
// Option 2: Merge changes
return {
...remote,
...local,
updatedAt: Math.max(local.updatedAt, remote.updatedAt),
};
// Option 3: Ask user
const choice = await showConflictDialog(local, remote);
return choice === 'local' ? local : remote;
}
3. Validate Before Sync
function validateTodo(todo: Todo): boolean {
if (!todo.id || !todo.text) return false;
if (todo.text.length > 500) return false;
return true;
}
async function syncTodo(todo: Todo) {
if (!validateTodo(todo)) {
throw new Error('Invalid todo');
}
// Proceed with sync
}
Resources
- Capacitor Network: https://capacitorjs.com/docs/apis/network
- Workbox: https://developer.chrome.com/docs/workbox
- IndexedDB: https://developer.mozilla.org/docs/Web/API/IndexedDB_API
- Offline First Manifesto: http://offlinefirst.org
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.