Swoff Swoff

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

BrowserLimitNotes
ChromeUp to free disk spaceConsiders available space
Firefox~50% of free diskShares with site data
Safari~50 MB hard limitEnforced strictly
EdgeSimilar to ChromeUses 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 idb
import { 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

DataLocationCleared when
Auth tokenmemoryAuth variablePage refresh
Active routesJS heapPage refresh
Fetched dataComponent stateUnmount
Cached responsesCache APIExplicit 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:

ComponentApproximate 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

On this page