Comprehensive Rules for building robust offline-capable Progressive Web Apps with TypeScript, React, Service Workers, IndexedDB/SQLite, and PowerSync.
Your users don't care about your network architecture. They care about getting work done—whether they're in a subway tunnel, rural coffee shop, or dealing with spotty conference Wi-Fi. Yet most web apps crash and burn the moment connectivity hiccups.
Every day, developers build apps that assume perfect connectivity. The results are predictable:
You've probably experienced this yourself—frantically refreshing a web app that won't load, losing work because the connection dropped at the worst moment.
These Cursor Rules transform your development approach by making offline capability the foundation, not an afterthought. Instead of building for perfect networks and patching in offline features, you'll design apps that work everywhere from the start.
The rules implement a complete offline-first architecture using TypeScript, React, Service Workers, IndexedDB/SQLite, and PowerSync—giving you enterprise-grade offline capabilities with modern web technologies.
// Fragile: Breaks on network issues
const saveDocument = async (doc: Document) => {
setLoading(true);
try {
await api.post('/documents', doc);
setSuccess(true);
} catch (error) {
// User loses work on network failure
setError('Save failed');
}
setLoading(false);
};
// Resilient: Always works, syncs when possible
const saveDocument = async (doc: Document) => {
// Immediate local save
const result = await storage.save(doc);
// Queue for sync
await syncQueue.enqueue({
type: 'CREATE_DOCUMENT',
payload: doc,
timestamp: Date.now()
});
// User sees immediate success
return result;
};
Transform shopping cart abandonment due to network issues:
// Robust checkout that works offline
const processCheckout = async (cart: CartItems) => {
// Save order locally immediately
const pendingOrder = await orderStorage.createPendingOrder(cart);
// Show success to user
showOrderConfirmation(pendingOrder.id);
// Process payment when online
syncQueue.enqueue({
type: 'PROCESS_PAYMENT',
orderId: pendingOrder.id,
priority: 'high'
});
};
Enable content creators to work anywhere:
// Content saves locally, publishes when connected
const saveDraft = async (content: ArticleDraft) => {
// Always succeeds locally
await contentStorage.saveDraft(content);
// Auto-save to server when online
if (connectivity.isOnline) {
syncQueue.prioritize('SAVE_DRAFT', content.id);
}
// Show local save confirmation immediately
showSaveIndicator('saved-locally');
};
# Set up the foundation
npm install workbox-window workbox-build sql.js @powersync/web
npm install -D @types/sql.js cypress
// src/service-worker/sw.ts
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
// Cache static assets aggressively
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [{
cacheKeyWillBeUsed: async ({ request }) => {
return `${request.url}?v=${self.__WB_MANIFEST_VERSION}`;
}
}]
})
);
// API responses with stale-while-revalidate
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [{
cacheWillUpdate: async ({ response }) => {
return response.status === 200 ? response : null;
}
}]
})
);
// src/storage/indexed-db.ts
interface StorageResult<T> {
success: boolean;
data?: T;
error?: StorageError;
}
export class OfflineStorage {
private db: IDBDatabase;
async save<T>(collection: string, data: T): Promise<StorageResult<T>> {
try {
const tx = this.db.transaction([collection], 'readwrite');
const store = tx.objectStore(collection);
const record = {
...data,
id: crypto.randomUUID(),
updated_at: Date.now(),
synced: false
};
await store.add(record);
await tx.complete;
return { success: true, data: record };
} catch (error) {
return {
success: false,
error: new StorageError('SAVE_FAILED', error.message)
};
}
}
}
// src/sync/enqueue.ts
export class SyncQueue {
async enqueue(job: SyncJob): Promise<void> {
await this.storage.addJob({
...job,
id: crypto.randomUUID(),
attempts: 0,
created_at: Date.now(),
status: 'pending'
});
// Trigger immediate sync if online
if (navigator.onLine) {
this.processQueue();
}
}
private async processQueue(): Promise<void> {
const pendingJobs = await this.storage.getPendingJobs();
for (const job of pendingJobs) {
try {
await this.executeJob(job);
await this.storage.markJobComplete(job.id);
} catch (error) {
await this.handleJobError(job, error);
}
}
}
}
// src/hooks/useConnectivity.ts
export const useConnectivity = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
// Trigger sync when coming back online
syncQueue.processQueue();
};
const handleOffline = () => {
setIsOnline(false);
setSyncStatus('offline');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return { isOnline, syncStatus };
};
Teams using these rules report:
// Automatic field-level merging
const resolveConflict = (local: Document, remote: Document): Document => {
return {
...remote,
// Preserve user's latest content changes
content: local.updated_at > remote.updated_at ? local.content : remote.content,
// Merge non-conflicting fields
tags: [...new Set([...local.tags, ...remote.tags])],
metadata: { ...remote.metadata, ...local.metadata }
};
};
// Only sync when conditions are optimal
const shouldSync = (): boolean => {
const battery = navigator.battery;
const connection = navigator.connection;
return (
navigator.onLine &&
(!battery || battery.charging || battery.level > 0.2) &&
(!connection || connection.effectiveType !== 'slow-2g')
);
};
Start building apps that your users can rely on, regardless of their connectivity. These Cursor Rules give you the complete offline-first architecture that modern applications demand.
Your users will thank you. Your support team will thank you. Your business metrics will thank you.
The question isn't whether your users will encounter connectivity issues—it's whether your app will handle them gracefully.
You are an expert in Offline-First Progressive Web Applications built with TypeScript, React, Service Workers, IndexedDB/SQLite, Workbox, and PowerSync.
Key Principles
- Treat offline capability as the default condition; design for zero-network first, then enhance for online.
- Keep all critical data and business logic local. Remote APIs are extensions, not requirements.
- Separate read/write layers: write immediately to local store, enqueue sync jobs, reconcile later.
- Give users explicit visibility: clearly show connectivity state, pending sync count, and conflict prompts.
- Optimize battery, CPU, and data: batch writes, compress payloads, defer heavy sync until Wi-Fi + charging.
- Encrypt sensitive data at rest (AES-256) and in transit (TLS) even within a service worker.
- Fail fast, recover gracefully: never crash on storage or network errors—fallback to last-known good state.
- Every PR must include offline scenario tests and regression cases.
TypeScript
- Target `es2022`, `moduleResolution=node`, `strict` enabled—no `any`, no implicit `this`.
- Prefer `interface` for contracts, `type` for utility unions/intersections.
- Use functional modules (`.ts`) and React Function Components (`.tsx`); no class components.
- File naming: `kebab-case` for modules (`sync-queue.ts`), `PascalCase` for components (`SyncBanner.tsx`).
- Always return typed `Promise<Result<T, SyncError>>` from async persistence/sync functions.
- Disallow direct `localStorage` calls; encapsulate storage via `storage/indexed-db.ts` or `storage/sqlite.ts`.
Error Handling and Validation
- Detect connectivity via the Navigator API (`navigator.onLine`, `online`/`offline` events) and fallback timer pings.
- Wrap every DB op in `try/catch`; surface a `StorageError` with `code`, `message`, `recoverable` flag.
- Early-return on invalid input; use Zod schemas for runtime validation of server payloads.
- Conflict strategy hierarchy: 1) Automatic merge (field-level, last-writer-wins timestamp), 2) Prompt user, 3) Admin review. Store conflict state in `conflict_queue` table.
- Maintain an idempotent sync algorithm—replay jobs until server ACK is recorded.
React / Next.js
- Only Function Components + Hooks.
- Use React Query for local cache + stale-while-revalidate; plugin the custom offline persister.
- Global connectivity & sync context via `ConnectivityProvider` and `SyncProvider`.
- Display `<SyncBanner>` when `syncQueue.length > 0` or error state.
- Prefetch essential data into IndexedDB on first load; show skeleton UI while hydrate.
Service Worker (Workbox)
- Register using `workbox-window`. Version via `self.__WB_MANIFEST`.
- Caching strategies:
• `CacheFirst` for static assets `/static/**` (max-age = 1y).
• `StaleWhileRevalidate` for API GET `/api/**` (max entries = 200, max-age = 24h).
• `NetworkOnly` for auth endpoints.
- Background Sync (`workbox-background-sync`) queue: `syncQueue` (max retention = 48h).
- Broadcast channel `sw-messages` for sync status updates to the client.
IndexedDB / SQLite (via WASM sql.js)
- Schema versioned via `DB_META` table; migrations in `/db/migrations/v{n}.sql`.
- Composite primary keys `[collection, id, updated_at]` for delta sync.
- All writes wrapped in a single transaction; batch size ≤ 500 rows.
- Add `updated_at` index for fast diff queries.
PowerSync
- Create logical collections mirroring server models; include `is_deleted` soft-delete flag.
- Use PowerSync client to generate change sets; push to `/sync/batch` endpoint.
- Pull strategy: incremental—server sends only rows with `updated_at > client_cursor`.
Testing
- Use Cypress with `--offline` flag and Chrome DevTools throttling presets.
- Provide fixtures for: full offline, intermittent (drop every 30s), reconnection.
- Jest unit tests must mock `indexedDB`, `navigator.onLine`, `BroadcastChannel`.
- Verify conflict resolution paths with deterministic timestamps.
Performance
- Enable IDB WASM compile caching (`initSqlJs({locateFile})`).
- Compress sync payloads with Brotli; fall back to gzip.
- Delta sync only—never full table dumps after initial seed.
- Prefetch next navigation routes using `<link rel="prefetch">` when idle + online.
Security
- Encrypt SQLite db with SQLCipher WASM build; store key in secure, user-scoped storage.
- Validate service-worker integrity via Subresource Integrity hashes.
- Obfuscate sync queue data in memory (zero-out after commit).
Accessibility & UX
- Offline banners conform to WCAG AA contrast (≥ 4.5:1).
- Provide terse titles + detailed tooltips for sync status icons.
- Announce connectivity changes via ARIA live region.
Directory Structure
```
src/
├─ components/
│ ├─ SyncBanner.tsx
│ ├─ icons/
│ └─ ...
├─ hooks/
│ ├─ useConnectivity.ts
│ └─ useSyncQueue.ts
├─ pages/
├─ service-worker/
│ ├─ sw.ts
│ └─ workbox-config.js
├─ storage/
│ ├─ indexed-db.ts
│ ├─ sqlite.ts
│ └─ migrations/
├─ sync/
│ ├─ enqueue.ts
│ ├─ conflict.ts
│ └─ power-sync.ts
└─ types/
└─ index.d.ts
```
Common Pitfalls
- Forgetting to bump DB schema version—migrations silently fail; always unit-test version upgrades.
- Relying solely on `navigator.onLine`—supplement with fetch heartbeat.
- Writing to cache before IndexedDB transaction completes; await `tx.done`.
- Infinite sync loop due to non-idempotent server endpoints—include `request_id` header.
Checklist Before Merge
- [ ] All new API endpoints have offline fallback.
- [ ] Conflict tests cover happy & unhappy paths.
- [ ] Service worker passes `npm run sw:lint` (no synchronous `readFileSync`).
- [ ] Lighthouse PWA score ≥ 95.
- [ ] Bundle size increase < 10 KB or justified.