Strategi offline-first: cache-first vs network-first, service worker vs storage native, TanStack Query persistence, SQLite community plugin, sync engine pattern, serta praktik CRUD yang tetap berfungsi tanpa jaringan lalu tersinkron.

Setelah di episode 16 kita mengoptimasi performance, pada episode ini kita membahas arsitektur offline-first — pola desain di mana aplikasi tetap berfungsi penuh tanpa koneksi internet. Di mobile, ini bukan fitur lux — ini ekspektasi dasar.
Mengapa offline-first penting? Karena mobile user sering berpindah jaringan: dari WiFi ke seluler, masuk lift, atau di area tanpa sinyal. Aplikasi yang crash saat offline akan kehilangan user.
Fetch dari cache dulu, baru network jika cache miss. Cocok untuk data yang jarang berubah (profile, settings).
async function cacheFirst(key: string, fetchFn: () => Promise<any>) {
const cached = await Preferences.get({ key });
if (cached.value) {
return JSON.parse(cached.value);
}
const data = await fetchFn();
await Preferences.set({ key, value: JSON.stringify(data) });
return data;
}Fetch dari network dulu, fallback ke cache jika offline. Cocok untuk data yang harus up-to-date (feed, messages).
async function networkFirst(key: string, fetchFn: () => Promise<any>) {
try {
const data = await fetchFn();
await Preferences.set({ key, value: JSON.stringify(data) });
return data;
} catch {
const cached = await Preferences.get({ key });
if (cached.value) {
return JSON.parse(cached.value);
}
throw new Error('No data available');
}
}Fetch dari cache, tampilkan, lalu update dari network di background. Cocok untuk data yang bisa stale sesaat (product catalog).
async function staleWhileRevalidate(key: string, fetchFn: () => Promise<any>) {
const cached = await Preferences.get({ key });
// Return cache immediately
if (cached.value) {
JSON.parse(cached.value);
}
// Revalidate in background
const data = await fetchFn();
await Preferences.set({ key, value: JSON.stringify(data) });
return data;
}| Aspek | Service Worker | Storage Native (Preferences/SQLite) |
|---|---|---|
| Scope | Cache HTTP response | Cache data aplikasi |
| Offline | Halaman web bisa di-load | Data tersedia untuk JS |
| Persist | Cache API | File-based storage |
| Complexity | Menengah | Rendah-menengah |
| Use case | Aplikasi web shell | Data business logic |
Rekomendasi: gunakan keduanya. Service worker untuk caching shell aplikasi (HTML/CSS/JS), storage native untuk data business logic.
import { QueryClient } from '@tanstack/react-query';
import { Preferences } from '@capacitor/preferences';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 menit
retry: 3,
retryDelay: 1000,
},
},
});
// Custom storage adapter
const storageAdapter = {
getItem: async (key: string) => {
const result = await Preferences.get({ key });
return result.value ? JSON.parse(result.value) : null;
},
setItem: async (key: string, value: any) => {
await Preferences.set({ key, value: JSON.stringify(value) });
},
removeItem: async (key: string) => {
await Preferences.remove({ key });
},
};Untuk aplikasi yang membutuhkan query kompleks offline:
npm install @capacitor-community/sqlite
npx cap syncimport { SQLite } from '@capacitor-community/sqlite';
async function initDatabase() {
const db = await SQLite.createConnection({
database: 'my-app',
version: 1,
});
await db.execute(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
synced INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
return db;
}
// Create (offline)
async function addNote(title: string, content: string) {
const db = await initDatabase();
await db.run(
'INSERT INTO notes (title, content, synced) VALUES (?, ?, 0)',
[title, content]
);
}
// Read (offline)
async function getNotes() {
const db = await initDatabase();
const result = await db.query('SELECT * FROM notes ORDER BY created_at DESC');
return result.values;
}
// Get unsynced notes
async function getUnsyncedNotes() {
const db = await initDatabase();
const result = await db.query('SELECT * FROM notes WHERE synced = 0');
return result.values;
}import { Network } from '@capacitor/network';
class SyncEngine {
private isSyncing = false;
async startSyncLoop() {
// Listen for connectivity changes
Network.addListener('networkStatusChange', (status) => {
if (status.connected && !this.isSyncing) {
this.syncPending();
}
});
// Try sync on startup
if ((await Network.getStatus()).connected) {
this.syncPending();
}
}
async syncPending() {
this.isSyncing = true;
const unsynced = await getUnsyncedNotes();
for (const note of unsynced) {
try {
await fetch('https://api.example.com/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
});
await markSynced(note.id);
} catch {
// Akan retry di loop berikutnya
}
}
this.isSyncing = false;
}
}Tip
Gunakan UUID sebagai id lokal, bukan auto-increment. Ini mencegah konflik id saat sync dari beberapa perangkat.
Pada episode 17 ini, kalian telah memahami:
Di episode 18 selanjutnya, kita akan membahas testing dan debugging di perangkat — Safari Web Inspector, chrome://inspect, unit test, e2e dengan Appium/Detox, serta praktik trace bug yang hanya muncul di perangkat fisik. Sampai jumpa!