Performance
Cache size limits, IndexedDB performance, and memory management for production.
Performance
Offline-first apps run in the browser with resource constraints. Here's how to manage performance at scale.
Cache Size Limits
Browsers limit Cache API storage, but the limits vary:
Browser Limits
| Browser | Limit | Notes |
|---|---|---|
| Chrome | Up to free disk space | Considers available space |
| Firefox | ~50% of free disk | Shares with site data |
| Safari | ~50 MB hard limit | Enforced strictly |
| Edge | Similar to Chrome | Uses disk space |
Monitoring Cache Size
async function getCacheSize(cacheName) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
let size = 0;
for (const request of keys) {
const response = await cache.match(request);
const blob = await response?.blob();
size += blob?.size || 0;
}
return size; // bytes
}Proactive Cache Trimming
Keep cache manageable:
const MAX_CACHE_ENTRIES = 200;
const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
async function trimRuntimeCache() {
const cache = await caches.open("swoff-runtime");
const keys = await cache.keys();
const now = Date.now();
// 1. Remove entries older than MAX_CACHE_AGE
for (const request of keys) {
const response = await cache.match(request);
const date = response?.headers.get("date");
if (date) {
const age = now - new Date(date).getTime();
if (age > MAX_CACHE_AGE_MS) {
await cache.delete(request);
}
}
}
// 2. Remove excess entries (keep most recent)
const remainingKeys = await cache.keys();
if (remainingKeys.length > MAX_CACHE_ENTRIES) {
const toDelete = remainingKeys.length - MAX_CACHE_ENTRIES;
for (let i = 0; i < toDelete; i++) {
await cache.delete(remainingKeys[i]);
}
}
}
// Run periodically
setInterval(trimRuntimeCache, 24 * 60 * 60 * 1000);iOS Safari Limits
iOS enforces strict limits:
async function getUsageBreakdown() {
const estimate = await navigator.storage.estimate();
return {
usage: estimate.usage, // bytes used
quota: estimate.quota, // bytes available
usagePercent: (estimate.usage / estimate.quota) * 100
};
}
// Warning threshold for iOS
if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
const { usagePercent } = await getUsageBreakdown();
if (usagePercent > 70) {
// Aggressively trim on iOS
await aggressiveTrim();
}
}IndexedDB Performance
Transaction Size
Large transactions block the database:
// Bad: Large transaction with many operations
async function bulkAdd(items) {
const db = await openDB();
const tx = db.transaction("todos", "readwrite"); // Long transaction
const store = tx.objectStore("todos");
for (const item of items) {
store.put(item); // All in one transaction = slow
}
}
// Good: Batch into smaller transactions
async function bulkAddBatched(items, batchSize = 50) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
await addBatch(batch);
await new Promise(r => setTimeout(r, 0)); // Yield to UI
}
}Indexes for Query Performance
Create indexes for frequently queried fields:
request.onupgradeneeded = (e) => {
const db = e.target.result;
const store = db.createObjectStore("todos", { keyPath: "id" });
// Add indexes for common queries
store.createIndex("by-date", "createdAt");
store.createIndex("by-category", "category");
store.createIndex("by-status", "completed");
};Lazy Loading
Don't load all data at once:
async function getTodosPaginated(offset = 0, limit = 20) {
const db = await openDB();
const tx = db.transaction("todos", "readonly");
const store = tx.objectStore("todos");
const index = store.index("by-date");
return new Promise((resolve, reject) => {
const request = index.openCursor(null, "prev"); // Most recent first
const results = [];
let count = 0;
let skipped = 0;
request.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
if (skipped < offset) {
skipped++;
cursor.continue();
} else if (count < limit) {
results.push(cursor.value);
count++;
cursor.continue();
}
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}IDB with Promises (Optional)
Native IndexedDB uses callbacks. Use idb wrapper for cleaner code:
npm install idbimport { openDB } from "idb";
const db = await openDB("my-app", 1, {
upgrade(db) {
db.createObjectStore("todos", { keyPath: "id" });
},
});
// Cleaner operations
await db.put("todos", { id: "1", title: "Task" });
const todo = await db.get("todos", "1");
const all = await db.getAll("todos");Memory Management
What Stays in Memory
| Data | Location | Cleared when |
|---|---|---|
| Auth token | memoryAuth variable | Page refresh |
| Active routes | JS heap | Page refresh |
| Fetched data | Component state | Unmount |
| Cached responses | Cache API | Explicit delete / quota |
Memory Pressure Indicators
function setupMemoryMonitoring() {
if (!performance.memory) return; // Only in Chrome
setInterval(() => {
const memory = (performance as any).memory;
const usedMB = (memory.usedJSHeapSize / 1024 / 1024).toFixed(1);
const totalMB = (memory.jsHeapSizeLimit / 1024 / 1024).toFixed(1);
if (memory.usedJSHeapSize > memory.jsHeapSizeLimit * 0.8) {
console.warn(`Memory pressure: ${usedMB}MB / ${totalMB}MB`);
// Trigger cleanup
trimRuntimeCache();
}
}, 5000);
}Release Unused Data
// Clear cached data when user logs out
async function onLogout() {
// Clear IndexedDB
const databases = await indexedDB.databases();
for (const db of databases) {
if (db.name) indexedDB.deleteDatabase(db.name);
}
// Clear Cache API
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
// Clear memory
memoryAuth = null;
}Bundle Impact
Swoff adds minimal code to your bundle:
| Component | Approximate Size |
|---|---|
| SW template | ~5KB minified |
| Build script | ~1KB |
| Client registration | ~2KB |
| API integration | ~3KB |
| Total core | ~10-15KB |
Compared to libraries:
- Workbox: ~30-100KB
- Uppy (PWA solution): ~100KB+
- Swoff: Zero runtime dependencies
Runtime Performance
Cache-First (Default)
Fastest - serve from cache if available:
// In SW fetch handler
const cached = await cache.match(request);
if (cached) return cached;
return fetch(request);Tradeoff: Users may see slightly stale data.
Stale-While-Revalidate
Serve cached immediately, update in background:
// Client: set header
fetchWithCache("/api/todos", { tags: ["todos"], staleWhileRevalidate: true });Tradeoff: Extra network requests, but instant load.
Network-First
Fresh data preferred:
// Use for real-time data, user-generated content
fetchWithCache("/api/notifications", { staleWhileRevalidate: false });Tradeoff: Slower offline, but always fresh when online.
Production Checklist
- Implement cache trimming (max entries + age)
- Monitor storage usage with
navigator.storage.estimate() - Add iOS-specific limits handling
- Batch large IndexedDB transactions
- Create indexes for query fields
- Add lazy loading for large datasets
- Clear sensitive data on logout
- Test with throttled network in DevTools
Next Steps
- IndexedDB Patterns — production DB setup
- Benchmarks — measure and optimize
Swoff