localStorage is not evil. It is just easy to misuse.
It works well for:
- a theme preference
- a small feature flag cache
- lightweight session hints
It is a poor fit for:
- large offline datasets
- draft systems with lots of content
- queues of sync operations
- image or binary payloads
The Main Limitation
The API is synchronous.
That means reads and writes happen on the main thread, which is exactly where you do not want large storage work happening in a modern app.
For small keys, that is usually fine.
For application-sized caches, it becomes a design smell.
When IndexedDB Is the Better Choice
IndexedDB is a much better fit when you need:
- structured records
- asynchronous access
- transactions
- larger client-side persistence
That is why serious offline-capable web apps usually end up there eventually.
The downside is ergonomics. The native API is not pleasant to use directly.
Why Libraries Help
Wrappers like Dexie make IndexedDB much easier to treat as part of the application architecture rather than a low-level browser oddity.
import Dexie, { type EntityTable } from "dexie";
interface Draft {
id: number;
title: string;
body: string;
}
const db = new Dexie("writer") as Dexie & {
drafts: EntityTable<Draft, "id">;
};
db.version(1).stores({
drafts: "++id, title",
});
That is often enough to make browser-side persistence feel maintainable.
A Better Rule of Thumb
Use:
localStorage for tiny preference-like state
- IndexedDB for real application data
That line will save a lot of frontend architecture pain.
Further Reading