IndexedDB vs LocalStorage: Your Guide to Storing Large Data and Smart Caching

IndexedDB vs LocalStorage: Your Guide to Storing Large Data and Smart Caching
Hit the 5MB LocalStorage limit? Learn when to use IndexedDB for storing large datasets, offline support, and smart caching in modern web apps and PWAs.
Mohammad Alhabil
Author
IndexedDB vs LocalStorage: Your Guide to Storing Large Data and Smart Caching
We've all tried to store large data in LocalStorage only to see this error:
QuotaExceededError: The quota has been exceeded.
True, LocalStorage is limited to about 5MB and designed to store small text strings. But when your project grows and you need to:
- Store thousands of messages or API data for offline use
- Save images or small files in a PWA
- Use smart caching to speed up your site and reduce server load
That's when IndexedDB comes in!
The Critical Difference: Performance & Threading
| Feature | LocalStorage | IndexedDB |
|---|---|---|
| Operation | Synchronous | Asynchronous |
| Thread | Blocks main thread | Runs in background |
| UI Impact | Can freeze UI with large data | Never blocks the UI |
LocalStorage: Synchronous = Blocking
// ❌ This blocks the main thread!
localStorage.setItem('largeData', JSON.stringify(hugeArray));
// User can't interact with the page during this operation
const data = JSON.parse(localStorage.getItem('largeData'));
IndexedDB: Asynchronous = Non-Blocking
// ✅ This runs in the background
const transaction = db.transaction(['store'], 'readwrite');
const store = transaction.objectStore('store');
store.put(hugeArray, 'largeData');
// User can keep interacting with the page!
Quick Comparison
| Feature | LocalStorage | SessionStorage | IndexedDB |
|---|---|---|---|
| Size Limit | ~5MB | ~5MB | 50MB+ (browser dependent) |
| Data Types | Strings only | Strings only | Objects, Blobs, Files, Arrays |
| Persistence | Forever | Until tab closes | Forever |
| API Style | Sync | Sync | Async |
| Indexing | No | No | Yes (queryable) |
| Transactions | No | No | Yes (ACID) |
Real-World Scenarios for IndexedDB
1️⃣ API Caching for Faster Sites
Store product data locally with IndexedDB. The site displays it instantly without waiting for the server, while refreshing in the background:
import { openDB } from 'idb';
// Initialize database
const db = await openDB('myApp', 1, {
upgrade(db) {
db.createObjectStore('products', { keyPath: 'id' });
},
});
// Cache-first strategy
async function getProducts(): Promise<Product[]> {
// 1. Return cached data immediately
const cached = await db.getAll('products');
if (cached.length > 0) {
// 2. Refresh in background (don't await)
refreshProductsInBackground();
return cached;
}
// 3. No cache? Fetch and store
const fresh = await fetchFromAPI('/api/products');
const tx = db.transaction('products', 'readwrite');
fresh.forEach(product => tx.store.put(product));
await tx.done;
return fresh;
}
async function refreshProductsInBackground() {
try {
const fresh = await fetchFromAPI('/api/products');
const tx = db.transaction('products', 'readwrite');
await tx.store.clear();
fresh.forEach(product => tx.store.put(product));
await tx.done;
} catch (error) {
// Silently fail - we have cached data
}
}
Benefits:
- ⚡ Instant display (no loading spinner)
- 📉 Less server pressure
- 📴 Works offline
2️⃣ Chat App with Offline Support
If the internet disconnects, store messages locally and send them when connectivity returns:
import { openDB, DBSchema } from 'idb';
interface ChatDB extends DBSchema {
messages: {
key: string;
value: {
id: string;
text: string;
timestamp: number;
status: 'pending' | 'sent' | 'failed';
};
indexes: { 'by-status': string };
};
}
const db = await openDB<ChatDB>('chatApp', 1, {
upgrade(db) {
const store = db.createObjectStore('messages', { keyPath: 'id' });
store.createIndex('by-status', 'status');
},
});
// Send message (works offline!)
async function sendMessage(text: string) {
const message = {
id: crypto.randomUUID(),
text,
timestamp: Date.now(),
status: 'pending' as const,
};
// Store locally first
await db.put('messages', message);
// Try to send
if (navigator.onLine) {
await syncMessage(message);
}
// If offline, will sync later
}
// Sync pending messages when online
async function syncPendingMessages() {
const pending = await db.getAllFromIndex('messages', 'by-status', 'pending');
for (const message of pending) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
});
message.status = 'sent';
await db.put('messages', message);
} catch {
message.status = 'failed';
await db.put('messages', message);
}
}
}
// Listen for connectivity
window.addEventListener('online', syncPendingMessages);
3️⃣ Storing Files and Images in PWA
LocalStorage doesn't support Blobs or files, but IndexedDB stores any data type efficiently:
import { openDB } from 'idb';
const db = await openDB('mediaCache', 1, {
upgrade(db) {
db.createObjectStore('images', { keyPath: 'url' });
},
});
// Cache image as Blob
async function cacheImage(url: string): Promise<string> {
// Check cache first
const cached = await db.get('images', url);
if (cached) {
return URL.createObjectURL(cached.blob);
}
// Fetch and cache
const response = await fetch(url);
const blob = await response.blob();
await db.put('images', { url, blob, cachedAt: Date.now() });
return URL.createObjectURL(blob);
}
// Usage in React
function CachedImage({ src, alt }: { src: string; alt: string }) {
const [objectUrl, setObjectUrl] = useState<string>();
useEffect(() => {
cacheImage(src).then(setObjectUrl);
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [src]);
if (!objectUrl) return <Skeleton />;
return <img src={objectUrl} alt={alt} />;
}
Making IndexedDB Easier
The raw IndexedDB API is verbose and callback-based. Use these libraries to simplify:
idb (Lightweight Promise Wrapper)
npm install idb
import { openDB } from 'idb';
const db = await openDB('myDB', 1, {
upgrade(db) {
db.createObjectStore('users', { keyPath: 'id' });
},
});
// Simple CRUD
await db.put('users', { id: 1, name: 'Mohammad' });
const user = await db.get('users', 1);
await db.delete('users', 1);
Dexie.js (Full-Featured ORM)
npm install dexie
import Dexie from 'dexie';
class MyDatabase extends Dexie {
users!: Dexie.Table<User, number>;
constructor() {
super('myDB');
this.version(1).stores({
users: '++id, name, email', // Auto-increment id, indexed fields
});
}
}
const db = new MyDatabase();
// Beautiful API
await db.users.add({ name: 'Mohammad', email: 'test@example.com' });
const users = await db.users.where('name').startsWith('Mo').toArray();
Best Practices
1. Design Your Schema Right
Create keys and indexes that make searching easy:
const db = await openDB('ecommerce', 1, {
upgrade(db) {
const store = db.createObjectStore('products', { keyPath: 'id' });
// Create indexes for common queries
store.createIndex('by-category', 'category');
store.createIndex('by-price', 'price');
store.createIndex('by-category-price', ['category', 'price']);
},
});
// Now you can query efficiently
const electronics = await db.getAllFromIndex('products', 'by-category', 'electronics');
2. Batch Large Data Operations
Store large datasets in batches instead of all at once:
async function storeInBatches<T>(
db: IDBPDatabase,
storeName: string,
items: T[],
batchSize = 100
) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const tx = db.transaction(storeName, 'readwrite');
batch.forEach(item => tx.store.put(item));
await tx.done;
// Let UI breathe between batches
await new Promise(r => setTimeout(r, 0));
}
}
3. Monitor Storage and Clean Up
// Check available storage
async function checkStorageQuota() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { usage, quota } = await navigator.storage.estimate();
const percentUsed = ((usage! / quota!) * 100).toFixed(2);
console.log(`Using ${percentUsed}% of available storage`);
if (usage! / quota! > 0.8) {
await cleanupOldCache();
}
}
}
// Clean old cached data
async function cleanupOldCache() {
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const tx = db.transaction('cache', 'readwrite');
let cursor = await tx.store.openCursor();
while (cursor) {
if (cursor.value.cachedAt < oneWeekAgo) {
await cursor.delete();
}
cursor = await cursor.continue();
}
}
4. Handle Offline → Online Sync
// Queue operations when offline
const offlineQueue: Array<() => Promise<void>> = [];
async function saveWithSync(data: any) {
// Always save locally
await db.put('data', data);
if (navigator.onLine) {
await syncToServer(data);
} else {
offlineQueue.push(() => syncToServer(data));
}
}
// Sync when back online
window.addEventListener('online', async () => {
for (const operation of offlineQueue) {
await operation();
}
offlineQueue.length = 0;
});
Decision Guide
| Use Case | Storage Choice |
|---|---|
| User preferences, theme | LocalStorage ✅ |
| Auth tokens (short-lived) | SessionStorage ✅ |
| Large API responses | IndexedDB ✅ |
| Offline data sync | IndexedDB ✅ |
| File/image caching | IndexedDB ✅ |
| Simple key-value (< 1KB) | LocalStorage ✅ |
| Queryable data | IndexedDB ✅ |
Interfaces you love, code you understand. 💡
Topics covered
Found this article helpful?
Share it with your network and help others learn too!

Written by Mohammad Alhabil
Frontend Developer & Software Engineer passionate about building beautiful and functional web experiences. I write about React, Next.js, and modern web development.
