
Capacitor Offline First
- 573 installs
- 57 repo stars
- Updated July 13, 2026
- cap-go/capgo-skills
capacitor-offline-first is a Claude Code skill that architects offline-first Capacitor apps with sync and caching for developers who need reliable mobile data when connectivity drops or API calls fail.
About
capacitor-offline-first is a mobile build skill from cap-go/capgo-skills for offline-first Capacitor architectures on iOS, Android, and web fallbacks. It layers UI, online/offline service modes, a SyncManager, and local storage via @capgo/capacitor-fast-sql KeyValueStore or IndexedDB. `@capacitor/network` listeners trigger `syncPendingChanges()` on reconnect; OfflineRepository tracks `syncStatus` of synced, pending, or conflict with last-write-wins resolution. Service worker examples use Workbox NetworkFirst API caching, CacheFirst images, and queued failed requests persisted locally. Patterns include optimistic UI updates, conflict dialogs, validation before sync, and sync status badges. Fast SQL production setup notes cover iOS localhost networking, Android cleartext exceptions, and optional sql.js web fallback with a dedicated sqlite-to-fast-sql skill for migrations. Developers reach for this skill when field users need Capacitor apps that read and write locally first, then reconcile with REST backends automatically.
- Complete offline-first architecture with online/offline mode switching
- Network detection using the official Capacitor Network plugin
- Local database strategies with Fast SQL and IndexedDB
- Data synchronization, caching patterns, and conflict resolution guidance
- Service worker integration for reliable caching
Capacitor Offline First by the numbers
- 573 all-time installs (skills.sh)
- Ranked #291 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cap-go/capgo-skills --skill capacitor-offline-firstAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 573 |
|---|---|
| repo stars | ★ 57 |
| Last updated | July 13, 2026 |
| Repository | cap-go/capgo-skills ↗ |
How do you build offline-first Capacitor mobile apps?
Add reliable offline support, local data persistence, and automatic synchronization to their Capacitor mobile apps.
Who is it for?
Capacitor developers building field or low-connectivity apps needing local SQL storage, background sync, and conflict handling.
Skip if: Online-only Capacitor shells or native Swift/Kotlin apps outside the Capacitor bridge ecosystem.
When should I use this skill?
User needs offline support, data sync, local database, caching, or connectivity handling in a Capacitor app.
What you get
OfflineRepository entities, SyncManager with conflict resolution, network-aware services, Workbox caches, and optimistic UI sync indicators.
- SyncManager module
- OfflineRepository stores
- Service worker cache config
By the numbers
- Architecture diagram spans 4 layers: UI, Service, Sync Manager, Local Database
- Documents 3 conflict resolution strategies: last-write-wins, merge, user prompt
- Workbox image cache example sets maxEntries 100 and maxAgeSeconds of 1 week
Files
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 │ │
│ │ (Fast SQL / IndexedDB) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘Network Detection
Using Capacitor Network Plugin
npm install @capacitor/network
npx cap syncimport { 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 Fast SQL
Installation
npm install @capgo/capacitor-fast-sql
npx cap syncBefore using Fast SQL in production, complete the required platform setup:
- iOS: allow localhost networking for the plugin transport.
- Android: add the localhost cleartext exception required by the plugin.
- Web: install
sql.jsif the app needs the web fallback.
Use the dedicated sqlite-to-fast-sql skill when you need the full platform checklist.
Database Setup
import { KeyValueStore } from '@capgo/capacitor-fast-sql';
class Database {
private store: Awaited<ReturnType<typeof KeyValueStore.open>> | null = null;
async open() {
if (this.store) return;
this.store = await KeyValueStore.open({
database: 'myapp',
store: 'data',
encrypted: false,
});
}
async set(key: string, value: any) {
await this.open();
await this.store!.set(key, value);
}
async get<T>(key: string): Promise<T | null> {
await this.open();
return this.store!.get<T>(key);
}
async remove(key: string) {
await this.open();
await this.store!.remove(key);
}
async keys(): Promise<string[]> {
await this.open();
return this.store!.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
) {}
getCollection(): string {
return this.collection;
}
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, options?: { markPending?: boolean }): Promise<void> {
item.updatedAt = Date.now();
if (options?.markPending ?? true) {
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 collection = repo.getCollection();
const response = await fetch(
`/api/${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' }, { markPending: false });
} else if (localItem.syncStatus === 'synced') {
// No local changes - update from server
await repo.save({ ...remoteItem, syncStatus: 'synced' }, { markPending: false });
} 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' }, { markPending: false });
}
}
}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
{
"version": "1.0.0",
"organization": "Capgo",
"date": "January 2026",
"abstract": "Guide to building offline-first Capacitor apps with data synchronization, caching strategies, conflict resolution, SQLite storage, and service workers.",
"triggers": [
"offline",
"offline first",
"sync data",
"work offline",
"no internet",
"cache",
"local storage"
],
"references": [
"https://capacitorjs.com/docs/apis/network",
"https://developer.chrome.com/docs/workbox",
"http://offlinefirst.org"
]
}
Related skills
How it compares
Use capacitor-offline-first for Capacitor local-first sync patterns; pair with sqlite-to-fast-sql when migrating existing SQLite schemas.
FAQ
Which plugins does capacitor-offline-first use for network?
capacitor-offline-first uses `@capacitor/network` with `Network.getStatus()` and `networkStatusChange` listeners to flip between online fetches and cached offline reads, triggering sync when connectivity returns.
What local database does capacitor-offline-first recommend?
capacitor-offline-first recommends `@capgo/capacitor-fast-sql` KeyValueStore for native storage, with IndexedDB or sql.js web fallback, plus platform setup for iOS localhost and Android cleartext.
How does capacitor-offline-first resolve sync conflicts?
capacitor-offline-first defaults to last-write-wins using `updatedAt` timestamps, with alternatives to merge fields or prompt the user via a conflict dialog when local pending edits collide with server data.