This comparison evaluates six distinct approaches to persisting data in the browser. js-cookie specializes in HTTP cookie management for server interaction and small state. store, store2, and localforage provide unified APIs over localStorage and sessionStorage, with localforage uniquely offering an asynchronous fallback to IndexedDB for larger datasets. idb-keyval is a minimal wrapper specifically for IndexedDB, bypassing synchronous storage limits. Finally, redux-persist is not a storage engine itself but a middleware layer that automates saving and rehydrating Redux state using any of the other engines. Choosing the right tool depends on data size, synchronicity requirements, and whether you need direct server access via cookies or complex client-side database features.
Persisting data in the browser is a fundamental requirement for modern web apps, whether you're saving a user's theme preference, caching API responses, or managing an offline-first database. The ecosystem offers a range of tools, from simple wrappers around localStorage to full asynchronous IndexedDB clients. Let's break down how idb-keyval, js-cookie, localforage, redux-persist, store, and store2 solve these problems differently.
The most critical decision is where your data physically resides. This dictates size limits, performance, and whether the server can see the data.
js-cookie writes to HTTP Cookies.
// js-cookie: Sets a cookie visible to the server
import Cookies from 'js-cookie';
Cookies.set('session_id', 'abc123', { expires: 7, path: '/' });
const value = Cookies.get('session_id');
store, store2, and localforage (by default) use localStorage.
// store2: Writes to localStorage synchronously
import store from 'store2';
store.set('user_prefs', { theme: 'dark' });
const prefs = store.get('user_prefs');
localforage can also use IndexedDB or WebSQL.
localStorage if needed.// localforage: Configures IndexedDB as the driver
import localforage from 'localforage';
localforage.config({
driver: localforage.INDEXEDDB,
name: 'myAppDB'
});
await localforage.setItem('large_image', blobData);
idb-keyval uses IndexedDB exclusively.
// idb-keyval: Direct IndexedDB access with Promises
import { set, get } from 'idb-keyval';
await set('avatar_blob', imageFile);
const avatar = await get('avatar_blob');
redux-persist is Engine Agnostic.
// redux-persist: Configures a storage engine (default is localStorage)
import { persistStore } from 'redux-persist';
import storage from 'redux-persist/lib/storage'; // uses localStorage
const persistor = persistStore(store);
How your code waits for data determines your app's responsiveness. Synchronous APIs are easier to write but can cause "jank" with large data. Asynchronous APIs require await but keep the UI smooth.
store, store2, and js-cookie are Synchronous.
// store: Synchronous read
const data = store.get('heavy_data'); // Blocks thread while parsing JSON
console.log(data);
idb-keyval and localforage are Asynchronous.
async/await or .then().// idb-keyval: Asynchronous read
const data = await get('heavy_data'); // Non-blocking
console.log(data);
redux-persist handles Rehydration Asynchronously.
// redux-persist: Waiting for rehydration
import { PersistGate } from 'redux-persist/integration/react';
<PersistGate loading={<Loading />} persistor={persistor}>
<App />
</PersistGate>
Organizing data prevents collisions, especially in large apps or when sharing storage across micro-frontends.
js-cookie, idb-keyval, and standard store use a Flat Key-Value structure.
// idb-keyval: Manual namespacing
await set('app_user_profile', profileData);
await set('app_settings_theme', themeData);
store2 has built-in Namespaces.
// store2: Using namespaces
const userStore = store.namespace('user');
const settingsStore = store.namespace('settings');
userStore.set('profile', { name: 'Alice' });
settingsStore.set('theme', 'dark');
// Keys in localStorage become 'user.profile' and 'settings.theme'
localforage supports Instances.
// localforage: Creating distinct instances
const imagesDB = localforage.createInstance({ name: 'images' });
const textDB = localforage.createInstance({ name: 'text' });
await imagesDB.setItem('logo', logoBlob);
redux-persist uses Reducer Keys.
// redux-persist: Persisting specific reducers
import { persistReducer } from 'redux-persist';
const persistedReducer = persistReducer(
{
key: 'root',
whitelist: ['auth', 'preferences'] // Only save these slices
},
rootReducer
);
Do you want to call save() every time data changes, or should it happen automatically?
store, store2, js-cookie, localforage, and idb-keyval require Manual Management.
// store2: Manual update on event
button.addEventListener('click', () => {
store.set('last_action', Date.now());
});
redux-persist provides Automatic Persistence.
// redux-persist: Automatic saving on state change
// No manual save call needed; it watches the store dispatches
const store = configureStore({ reducer: persistedReducer });
It is vital to choose a library that will be supported in the future.
store (store.js): While still functional, it is considered legacy. The community has largely moved to store2, which offers better TypeScript support, namespaces, and active maintenance. Avoid store for new greenfield projects.localforage: Still widely used, but note that its WebSQL backend is deprecated in browsers. It relies heavily on IndexedDB now, which is good, but idb-keyval is often preferred for pure IndexedDB tasks due to smaller bundle size.redux-persist: Actively maintained and the standard for Redux persistence, though developers should be aware of the shift towards React Context and server-state tools (like TanStack Query) which sometimes reduce the need for complex Redux persistence.| Feature | js-cookie | store2 | idb-keyval | localforage | redux-persist |
|---|---|---|---|---|---|
| Backend | Cookies | LocalStorage | IndexedDB | IndexedDB/LocalStorage | Configurable |
| Sync/Async | Sync | Sync | Async | Async | Async (Rehydration) |
| Max Size | ~4KB | ~5MB | Unlimited* | Unlimited* | Depends on Engine |
| Server Access | ✅ Yes | ❌ No | ❌ No | ❌ No | ❌ No |
| Best For | Auth, Session | Settings, Cache | Blobs, Large Data | Legacy, Large Data | Redux State |
* Subject to browser disk quota policies.
js-cookie is your go-to for anything the server needs to see, like authentication tokens. Keep it lightweight.
store2 is the modern replacement for store. Use it for simple, synchronous user preferences and small caches where you don't want to deal with Promises.
idb-keyval is the specialist for heavy lifting. If you are building an offline-first app storing images, audio, or massive JSON logs, this is the most efficient, modern choice.
localforage acts as a bridge. Use it if you need the power of IndexedDB but want an API that feels like localStorage, or if you need to support very old browsers gracefully.
redux-persist is the glue for Redux apps. It saves you from writing boilerplate but adds complexity to your store setup. Only use it if you are deeply invested in the Redux ecosystem.
Final Thought: Don't over-engineer. If you just need to remember a user's dark mode preference, store2 or even native localStorage is enough. If you are building the next Google Docs offline, reach for idb-keyval.
Choose idb-keyval when you need to store large binary blobs (like images or audio) or massive datasets that exceed localStorage limits. It is ideal for scenarios where you can work with asynchronous Promises and need the performance of IndexedDB without the verbosity of the native API. Avoid this if you need synchronous access or simple key-value pairs for small configuration settings.
Choose js-cookie when your data needs to be read by the server (via HTTP headers) or when you require strict expiration handling and path/domain scoping. It is the standard choice for authentication tokens, session IDs, and user preferences that must persist across subdomains. Do not use it for large client-side data, as cookies are sent with every request and have strict size limits (typically 4KB).
Choose localforage if you need a simple, synchronous-looking API but must support large data sizes or older browsers that lack robust IndexedDB support. It automatically selects the best available storage backend (IndexedDB, WebSQL, or localStorage). This is the best fit for legacy projects or applications that need to scale storage capacity without refactoring the entire data access layer to be asynchronous.
Choose redux-persist if your application uses Redux and you want to automatically persist the store to local storage without writing boilerplate save/load logic. It is essential for maintaining user state across page reloads in complex Redux architectures. Note that you must pair this with a storage engine (like redux-persist/lib/storage which uses localStorage, or a custom IndexedDB engine) to function.
Choose store (often imported as store.js) only for maintaining legacy codebases that already depend on it. It provides a basic synchronous wrapper around localStorage with JSON serialization. For new projects, prefer store2 or native APIs, as store has seen less active modernization compared to its successors and lacks some of the namespace features found in store2.
Choose store2 when you need a lightweight, synchronous wrapper around localStorage or sessionStorage with added features like namespaces and event hooks. It is perfect for simple user preferences, feature flags, or caching small API responses where asynchronous code would add unnecessary complexity. It offers a cleaner API and better TypeScript support than the original store package.
This is a super-simple promise-based keyval store implemented with IndexedDB, originally based on async-storage by Mozilla.
It's small and tree-shakeable. If you only use get/set, the library is 295 bytes (brotli'd), if you use all methods it's 573 bytes.
localForage offers similar functionality, but supports older browsers with broken/absent IDB implementations. Because of that, it's orders of magnitude bigger (~7k).
This is only a keyval store. If you need to do more complex things like iteration & indexing, check out IDB on NPM (a little heavier at 1k). The first example in its README is how to create a keyval store.
npm install idb-keyval
Now you can require/import idb-keyval:
import { get, set } from 'idb-keyval';
If you're targeting IE10/11, use the compat version, and import a Promise polyfill.
// Import a Promise polyfill
import 'es6-promise/auto';
import { get, set } from 'idb-keyval/compat';
A well-behaved bundler should automatically pick the ES module or the CJS module depending on what it supports, but if you need to force it either way:
idb-keyval/dist/index.js EcmaScript module.idb-keyval/dist/index.cjs CommonJS module.Legacy builds:
idb-keyval/compat transpiled for older browsers; a well-behaved bundler will pick the ES module or CJS module as appropriate.idb-keyval/umd UMD module, transpiled for older browsers; a well-behaved bundler will pick the appropriate module.idb-keyval/dist/compat.js EcmaScript module, transpiled for older browsers.idb-keyval/dist/compat.cjs CommonJS module, transpiled for older browsers.idb-keyval/dist/umd.js UMD module, also transpiled for older browsers.These built versions are also available on jsDelivr, e.g.:
<script src="https://cdn.jsdelivr.net/npm/idb-keyval@6/dist/umd.js"></script>
<!-- Or in modern browsers: -->
<script type="module">
import { get, set } from 'https://cdn.jsdelivr.net/npm/idb-keyval@6/+esm';
</script>
import { set } from 'idb-keyval';
set('hello', 'world');
Since this is IDB-backed, you can store anything structured-clonable (numbers, arrays, objects, dates, blobs etc), although old Edge doesn't support null. Keys can be numbers, strings, Dates, (IDB also allows arrays of those values, but IE doesn't support it).
All methods return promises:
import { set } from 'idb-keyval';
set('hello', 'world')
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
import { get } from 'idb-keyval';
// logs: "world"
get('hello').then((val) => console.log(val));
If there is no 'hello' key, then val will be undefined.
Set many keyval pairs at once. This is faster than calling set multiple times.
import { set, setMany } from 'idb-keyval';
// Instead of:
Promise.all([set(123, 456), set('hello', 'world')])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
// It's faster to do:
setMany([
[123, 456],
['hello', 'world'],
])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
This operation is also atomic – if one of the pairs can't be added, none will be added.
Get many keys at once. This is faster than calling get multiple times. Resolves with an array of values.
import { get, getMany } from 'idb-keyval';
// Instead of:
Promise.all([get(123), get('hello')]).then(([firstVal, secondVal]) =>
console.log(firstVal, secondVal),
);
// It's faster to do:
getMany([123, 'hello']).then(([firstVal, secondVal]) =>
console.log(firstVal, secondVal),
);
Transforming a value (eg incrementing a number) using get and set is risky, as both get and set are async and non-atomic:
// Don't do this:
import { get, set } from 'idb-keyval';
get('counter').then((val) =>
set('counter', (val || 0) + 1);
);
get('counter').then((val) =>
set('counter', (val || 0) + 1);
);
With the above, both get operations will complete first, each returning undefined, then each set operation will be setting 1. You could fix the above by queuing the second get on the first set, but that isn't always feasible across multiple pieces of code. Instead:
// Instead:
import { update } from 'idb-keyval';
update('counter', (val) => (val || 0) + 1);
update('counter', (val) => (val || 0) + 1);
This will queue the updates automatically, so the first update set the counter to 1, and the second update sets it to 2.
Delete a particular key from the store.
import { del } from 'idb-keyval';
del('hello');
Delete many keys at once. This is faster than calling del multiple times.
import { del, delMany } from 'idb-keyval';
// Instead of:
Promise.all([del(123), del('hello')])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
// It's faster to do:
delMany([123, 'hello'])
.then(() => console.log('It worked!'))
.catch((err) => console.log('It failed!', err));
Clear all values in the store.
import { clear } from 'idb-keyval';
clear();
Get all entries in the store. Each entry is an array of [key, value].
import { entries } from 'idb-keyval';
// logs: [[123, 456], ['hello', 'world']]
entries().then((entries) => console.log(entries));
Get all keys in the store.
import { keys } from 'idb-keyval';
// logs: [123, 'hello']
keys().then((keys) => console.log(keys));
Get all values in the store.
import { values } from 'idb-keyval';
// logs: [456, 'world']
values().then((values) => console.log(values));
By default, the methods above use an IndexedDB database named keyval-store and an object store named keyval. If you want to use something different, see custom stores.