| name | capacitor-offline-first |
| description | Guide to building offline-first Capacitor apps with data synchronization, caching strategies, and conflict resolution. Covers Fast SQL, 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 │ │
│ │ (Fast SQL / IndexedDB) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Network Detection
Using Capacitor Network Plugin
npm install @capacitor/network
npx cap sync
import { Network } from '@capacitor/network';
const status = await Network.getStatus();
console.log('Connected:', status.connected);
console.log('Connection type:', status.connectionType);
Network.addListener('networkStatusChange', (status) => {
console.log('Network status changed:', status.connected);
if (status.connected) {
syncManager.syncPendingChanges();
} else {
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 this.getCachedData(url);
}
try {
const response = await fetch(url, options);
const data = await response.();
.(url, data);
data;
} (error) {
.(url);
}
}
}
Local Database with Fast SQL
Installation
npm install @capgo/capacitor-fast-sql
npx cap sync
Before 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.js if 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.();
.!.<T>(key);
}
() {
.();
.!.(key);
}
(): <[]> {
.();
.!.();
}
}
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);
}
}
items;
}
(: ): <T | > {
..<T>();
}
(: T, ?: { ?: }): <> {
item. = .();
(options?. ?? ) {
item. = ;
}
..(, item);
}
(: ): <> {
item = .(id);
(item) {
item. = ;
(item ). = ;
..(, item);
}
}
(): <T[]> {
all = .();
all.( item. === );
}
(: ): <> {
item = .(id);
(item) {
item. = ;
..(, 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.(repo);
}
} {
. = ;
}
}
() {
pending = repo.();
( item pending) {
{
((item ).) {
.(item);
} {
.(item);
}
repo.(item.);
} (error) {
.(, item., error);
}
}
.(repo);
}
() {
(, {
: ,
: { : },
: .(item),
});
}
() {
(, {
: ,
});
}
() {
lastSync = .(repo);
collection = repo.();
response = (
);
remoteItems = response.();
( remoteItem remoteItems) {
localItem = repo.(remoteItem.);
(!localItem) {
repo.({ ...remoteItem, : }, { : });
} (localItem. === ) {
repo.({ ...remoteItem, : }, { : });
} {
.(localItem, remoteItem, repo);
}
}
.(repo, .());
}
() {
(local. > remote.) {
local. = ;
repo.(local);
} {
repo.({ ...remote, : }, { : });
}
}
}
Service Worker Caching
Register Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
Service Worker with Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
precacheAndRoute(self.__WB_MANIFEST);
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
})
);
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
{
expiration: {
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60,
},
},
],
})
);
(
request. === ,
({
: ,
})
);
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',
};
await this.repo.save(todo);
this.syncManager.syncAll().catch(console.error);
return todo;
}
async toggleComplete(id: string): Promise<Todo> {
const todo = await this..(id);
(!todo) ();
todo. = !todo.;
..(todo);
..().(.);
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();
(!status.) ;
(.. > ) {
request = .[];
{
(request., {
: request.,
: request.,
: request.,
});
..();
.();
} (error) {
;
}
}
}
}
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> {
return local.updatedAt > remote.updatedAt ? local : remote;
return {
...remote,
...local,
updatedAt: Math.max(local.updatedAt, remote.updatedAt),
};
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');
}
}
Resources