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
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');
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');
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);
You can also force compression on any item:
await cache.set('config', smallData, {
forceCompress: true,
});
🎯 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' });
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');
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
});
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();
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' },
]);
🔍 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+$/);
🔄 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();
});
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));
🔌 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
],
});
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());
📊 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);
💡 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>
);
}
🖼️ 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');
📤 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,
});
📊 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
- 📦 npm: cache-craft-engine
- ⭐ GitHub: MJavadSF/CacheCraft
- 🐛 Issues: GitHub Issues
💬 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)