DEV Community

Stop Losing Your API Calls: Build a Production-Ready Browser Cache in 5 Minutes

If you've ever opened DevTools and watched the same API call fire over and over again — for data that hasn't changed in hours — this article is for you.

I've been there. A dashboard that re-fetches user profiles on every tab switch. A product page that hits the database every single render. A PWA that breaks the moment the user goes offline.

The solution isn't complicated. It's caching. But doing it right in the browser — persistent, smart, secure — is where things get interesting.

That's why I built CacheCraft — an enterprise-grade IndexedDB caching library for modern web apps. Zero dependencies. ~10KB gzipped. TypeScript-native.

Let me show you what it can do.


🤔 Why Not localStorage? Why Not Memory?

Before we dive in, let's address the obvious.

Storage Persistent Size Limit Structured Data Async
Memory (variable) ❌ Gone on reload RAM only
localStorage ~5MB ❌ Strings only ❌ Blocking
IndexedDB Hundreds of MBs
CacheCraft Configurable ✅ + Compressed

localStorage is synchronous and limited to 5MB. Memory cache disappears on refresh. IndexedDB is powerful but notoriously painful to work with directly.

CacheCraft sits on top of IndexedDB and gives you a clean, powerful API — with compression, encryption, eviction strategies, and more — without the boilerplate.


📦 Installation

npm install cache-craft-engine
Enter fullscreen mode Exit fullscreen mode

That's it. Zero dependencies.


🚀 Getting Started: The Basics

import { CacheEngine } from 'cache-craft-engine';

const cache = new CacheEngine();

// Store anything
await cache.set('user', { id: 1, name: 'Ali', role: 'admin' });

// Retrieve it — even after a page reload
const user = await cache.get('user');
console.log(user); // { id: 1, name: 'Ali', role: 'admin' }

// Check existence
const exists = await cache.has('user'); // true

// Remove
await cache.remove('user');
Enter fullscreen mode Exit fullscreen mode

Simple. But this is just the surface.


⏱️ TTL: Make Your Cache Expire Automatically

// Cache for 10 minutes, then auto-expire
await cache.set('live-scores', scoresData, {
  ttl: 10 * 60 * 1000,
});

// After 10 minutes, this returns null automatically
const scores = await cache.get('live-scores');
Enter fullscreen mode Exit fullscreen mode

No more stale data lingering forever.


🗜️ Automatic Compression

Got large JSON responses? CacheCraft automatically compresses them using the native CompressionStream API — achieving 60–80% size reduction with zero effort on your part.

const cache = new CacheEngine({
  compressionThreshold: 10 * 1024, // Compress anything over 10KB
});

// This 500KB analytics payload gets stored as ~100KB
await cache.set('analytics-report', hugeDataset);
Enter fullscreen mode Exit fullscreen mode

You can also force compression on any item:

await cache.set('config', smallData, {
  forceCompress: true,
});
Enter fullscreen mode Exit fullscreen mode

🎯 7 Eviction Strategies — Pick What Fits

When your cache fills up, what gets removed first? The answer depends on your use case.

// LRU — Least Recently Used (default, great for general use)
new CacheEngine({ evictionStrategy: 'lru' });

// LFU — Least Frequently Used (best for "hot data")
new CacheEngine({ evictionStrategy: 'lfu' });

// FIFO — First In First Out (predictable, simple)
new CacheEngine({ evictionStrategy: 'fifo' });

// Priority — You decide what's important
new CacheEngine({ evictionStrategy: 'priority' });
await cache.set('critical-config', data, { priority: 100 });

// TTL — Evict items closest to expiring
new CacheEngine({ evictionStrategy: 'ttl' });

// Size — Remove the largest items first
new CacheEngine({ evictionStrategy: 'size' });

// ARC — Adaptive Replacement Cache (self-tuning, best of LRU+LFU)
new CacheEngine({ evictionStrategy: 'arc' });
Enter fullscreen mode Exit fullscreen mode

Most libraries give you one strategy. CacheCraft gives you seven.


🔐 Encryption for Sensitive Data

Never cache auth tokens, user PII, or payment info without encryption. CacheCraft uses AES-GCM (via the native WebCrypto API) — no external crypto library needed.

const cache = new CacheEngine({
  encryptionKey: 'your-secret-key-must-be-at-least-32-chars',
});

// Encrypted at rest in IndexedDB
await cache.set('auth-token', token, { encrypt: true });

// Automatically decrypted on retrieval
const myToken = await cache.get('auth-token');
Enter fullscreen mode Exit fullscreen mode

The data is unreadable in DevTools. Your users' data stays safe.


🔄 Stale-While-Revalidate: The Best UX Pattern

This is my favorite feature. Show cached data instantly, then silently refresh it in the background.

const profile = await cache.get('user-profile', {
  staleWhileRevalidate: true,
  revalidate: async () => {
    const res = await fetch('/api/me');
    return res.json();
  },
  ttlOnRevalidate: 5 * 60 * 1000, // Cache fresh data for 5 min
});
Enter fullscreen mode Exit fullscreen mode

Result: Your UI renders instantly with cached data. The user never sees a loading spinner. Fresh data replaces it seamlessly in the background.

This is how modern apps should work.


🏷️ Namespaces: Organize Your Cache Like a Pro

Don't dump everything into a single flat cache. Use namespaces to isolate concerns:

const userCache = cache.namespace('users');
const apiCache  = cache.namespace('api');
const mediaCache = cache.namespace('media');

await userCache.set('profile-42', userData);
await apiCache.set('products', productsData);
await mediaCache.setBlob('avatar-42', imageBlob);

// Clear only user cache — doesn't touch the others
await userCache.clear();
Enter fullscreen mode Exit fullscreen mode

Each namespace is completely isolated.


📦 Batch Operations for Performance

Making multiple cache calls one-by-one adds latency. Use batch operations instead:

// Set 1000 items in a single efficient operation
await cache.batchSet([
  { key: 'user-1', value: user1, options: { ttl: 60000 } },
  { key: 'user-2', value: user2, options: { ttl: 60000 } },
  { key: 'user-3', value: user3, options: { ttl: 60000 } },
  // ... as many as you need
]);

// Retrieve them all at once
const results = await cache.batchGet([
  { key: 'user-1' },
  { key: 'user-2' },
  { key: 'user-3' },
]);
Enter fullscreen mode Exit fullscreen mode

🔍 Advanced Queries with Tags

Tag your cache entries and query them later:

// Tag entries on set
await cache.set('iphone-15', productData, {
  tags: ['products', 'electronics', 'featured'],
  priority: 8,
});

await cache.set('macbook-pro', laptopData, {
  tags: ['products', 'electronics'],
  priority: 5,
});

// Later: find everything tagged 'electronics', sorted by access time
const electronics = await cache.query({
  tags: ['electronics'],
  sortBy: 'lastAccessed',
  sortOrder: 'desc',
  limit: 20,
});

// Find by key pattern
const userKeys = await cache.keys(/^user-\d+$/);
Enter fullscreen mode Exit fullscreen mode

🔄 Cross-Tab Synchronization

If your app opens in multiple tabs, keep the cache in sync automatically:

const cache = new CacheEngine({
  enableSync: true,
});

// A change in Tab A is automatically reflected in Tab B
await cache.set('cart-items', updatedCart);

// React to sync events
cache.on('sync', (data) => {
  console.log('Synced from another tab:', data.key);
  refreshUI();
});
Enter fullscreen mode Exit fullscreen mode

No more stale data in background tabs.


🔔 Event System

React to every cache operation:

cache.on('hit',   (data) => analytics.track('cache_hit',   data.key));
cache.on('miss',  (data) => analytics.track('cache_miss',  data.key));
cache.on('evict', (data) => console.warn('Evicted:', data.metadata.keys));
cache.on('error', (data) => Sentry.captureException(data.error));
Enter fullscreen mode Exit fullscreen mode

🔌 Plugin System

CacheCraft ships with 12 built-in plugins, and you can write your own.

import {
  LoggerPlugin,
  MetricsPlugin,
  ValidationPlugin,
  TTLRefreshPlugin,
} from 'cache-craft-engine';

const cache = new CacheEngine({
  plugins: [
    new LoggerPlugin(),              // Log all operations
    new MetricsPlugin(),             // Track performance metrics
    new ValidationPlugin(),          // Validate before caching
    new TTLRefreshPlugin(3600000),   // Auto-extend TTL on access
  ],
});
Enter fullscreen mode Exit fullscreen mode

Writing your own plugin is straightforward:

class SentryPlugin {
  name = 'sentry-plugin';

  async onError(error, operation) {
    Sentry.captureException(error, {
      extra: { operation }
    });
  }

  async afterSet(key, value, entry) {
    if (entry.size > 1024 * 1024) {
      Sentry.captureMessage(`Large cache entry: ${key} (${entry.size} bytes)`);
    }
  }
}

cache.use(new SentryPlugin());
Enter fullscreen mode Exit fullscreen mode

📊 Admin Panel & Health Monitoring

Built-in monitoring — no external tools needed:

import { CacheAdminPanel } from 'cache-craft-engine';

const admin = new CacheAdminPanel(cache);

const data = await admin.getData();

console.log('Hit Rate:',       data.stats.hitRate);      // e.g. 0.94
console.log('Total Size:',     data.stats.totalSize);    // e.g. 24MB
console.log('Compression:',    data.stats.compressionRatio); // e.g. 0.72
console.log('Health Status:',  data.health.status);     // 'healthy' | 'warning' | 'critical'
console.log('Top Keys:',       data.topKeys);
console.log('Alerts:',         data.health.recommendations);

// Generate a full text report
const report = await admin.generateReport();
console.log(report);
Enter fullscreen mode Exit fullscreen mode

💡 Real-World Example: React Data Fetching Hook

Here's how I use CacheCraft in production React apps:

import { useEffect, useState } from 'react';
import { CacheEngine } from 'cache-craft-engine';

const cache = new CacheEngine({
  enableStats: true,
  evictionStrategy: 'lfu',
});

function useCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl = 5 * 60 * 1000
) {
  const [data, setData]       = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState<Error | null>(null);

  useEffect(() => {
    async function load() {
      try {
        // Stale-while-revalidate for instant UI
        const result = await cache.get<T>(key, {
          staleWhileRevalidate: true,
          revalidate: fetcher,
          ttlOnRevalidate: ttl,
        });

        if (!result) {
          const fresh = await fetcher();
          await cache.set(key, fresh, { ttl, tags: ['react-query'] });
          setData(fresh);
        } else {
          setData(result);
        }
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    }
    load();
  }, [key]);

  const invalidate = async () => {
    await cache.remove(key);
    setLoading(true);
  };

  return { data, loading, error, invalidate };
}

// Usage — dead simple
function UserProfile({ userId }: { userId: number }) {
  const { data, loading, error, invalidate } = useCache(
    `user-${userId}`,
    () => fetch(`/api/users/${userId}`).then(r => r.json()),
  );

  if (loading) return <Skeleton />;
  if (error)   return <ErrorMessage error={error} />;

  return (
    <div>
      <h1>{data?.name}</h1>
      <button onClick={invalidate}> Refresh</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

🖼️ Example: PWA Image Caching

Offline-first image loading for PWAs:

const imageCache = new CacheEngine({
  namespace: 'images',
  maxSize: 100 * 1024 * 1024, // 100MB
  evictionStrategy: 'lru',
});

async function getCachedImage(url: string): Promise<string> {
  const cached = await imageCache.getBlob(url);

  if (cached) {
    return URL.createObjectURL(cached); // Instant
  }

  const response = await fetch(url);
  const blob = await response.blob();

  await imageCache.setBlob(url, blob, {
    ttl: 7 * 24 * 60 * 60 * 1000, // 7 days
  });

  return URL.createObjectURL(blob);
}

// Images load instantly on repeat visits — even offline
img.src = await getCachedImage('https://cdn.example.com/hero.jpg');
Enter fullscreen mode Exit fullscreen mode

📤 Export & Import: Backup Your Cache

// Backup
const exportData = await cache.export({
  includeExpired: false,
  compress: true,
});

const blob = new Blob([JSON.stringify(exportData)], {
  type: 'application/json',
});
downloadFile('cache-backup.json', blob);

// Restore on another device or session
await cache.import(exportData, {
  overwrite: true,
  skipInvalid: true,
});
Enter fullscreen mode Exit fullscreen mode

📊 Performance Benchmarks

Operation Average Time
get 1–3ms
set 2–5ms
batchSet (100 items) 15–30ms
Compression (100KB JSON) 5–15ms
Encryption (AES-GCM) ~5ms overhead
Eviction check 10–50ms

Bundle size: ~30KB minified, ~10KB gzipped. For comparison, localforage is ~29KB gzipped.


🌐 Browser Support

Browser Min Version
Chrome 80+
Edge 80+
Firefox 113+
Safari 16.4+

Requires: IndexedDB, CompressionStream, WebCrypto, BroadcastChannel.


🆚 CacheCraft vs. The Alternatives

Feature CacheCraft localforage idb-keyval memory-cache
Persistent
Compression ✅ Auto
Encryption ✅ AES-GCM
TTL
Eviction Strategies ✅ 7
Tags & Query
Tab Sync
Plugin System
TypeScript ✅ Native Partial Partial
Bundle Size (gz) ~10KB ~29KB ~1KB ~1KB
Zero Dependencies

idb-keyval is great for simple key-value storage. localforage is a solid fallback adapter. But neither handles TTL, eviction, encryption, or cross-tab sync — and that's exactly the gap CacheCraft fills.


🛣️ Roadmap

  • [ ] React/Vue hooks package (cache-craft-react, cache-craft-vue)
  • [ ] Service Worker integration
  • [ ] Cache warming from server-side hints
  • [ ] Shared Worker support for even better tab sync
  • [ ] DevTools browser extension

🎯 When Should You Use CacheCraft?

Use it when you need:

  • API response caching that survives page reloads
  • Offline-first / PWA data persistence
  • Large datasets that benefit from compression
  • Sensitive data that needs encryption at rest
  • Fine-grained control over cache eviction
  • Production monitoring and cache analytics

You probably don't need it for:

  • Simple session-scoped state → use React state or Zustand
  • Tiny amounts of config data → localStorage is fine
  • Server-side rendering → use a server cache like Redis

🚀 Get Started Now

npm install cache-craft-engine
Enter fullscreen mode Exit fullscreen mode

💬 I'd Love Your Feedback

I built CacheCraft to solve a real problem I faced on production projects. If you try it out, I'd love to hear:

  • Which eviction strategy fits your use case best?
  • What features are missing for your project?
  • Any performance results you've seen in production?

Drop a comment below or open a GitHub Discussion. Every piece of feedback helps make the library better. ⭐


If this article saved you some API calls (and some AWS bills), consider starring the repo — it helps more developers find the project.

Top comments (0)