IndexedDB for Browser Games: Persistent Local State That Survives a Refresh

Ask any browser-game developer what happens when a player hits refresh mid-session, and you will hear the same wince. If your state lived in a JavaScript variable, it is gone — the level, the inventory, the settings, all of it.
localStorage feels like the obvious patch, and for a high-score line it is fine. But the moment you persist a save slot holding a few thousand entities, or try to cache a 4MB sprite atlas, you hit its two hard walls: a synchronous API that blocks the frame you are trying to render, and a storage ceiling most browsers cap near 5MB.
IndexedDB is the persistence layer the platform actually shipped for this job, and it is chronically misunderstood. Treat it as a transactional, asynchronous object database that lives in the browser and survives a refresh, a tab close, and usually a full browser restart.
IndexedDB is a browser-native, asynchronous object database that stores structured data and binary blobs on the device. It is transactional, non-blocking, and holds hundreds of megabytes — enough for save slots, settings, and cached assets that outlive a refresh.
Why localStorage Fails Browser Games
The problem with localStorage is not that it is small — it is that it is synchronous and stringly-typed. Every read and write happens on the main thread, so a large JSON.parse on load can stall the first frame while the player stares at a frozen canvas.
You also cannot store binary data directly, which matters the instant you want to cache textures or audio. Everything has to be serialized to a string, and base64-encoding a blob inflates it by roughly 33% against a budget that was already tight.
| Capability | localStorage | IndexedDB |
|---|---|---|
| API | Synchronous, blocks the frame | Asynchronous, event-based |
| Typical quota | ~5MB per origin | Hundreds of MB to GBs |
| Data types | Strings only | Structured clones, Blobs, ArrayBuffers |
| Queries | None (manual) | Indexes, cursors, key ranges |
| Transactions | None | Atomic, multi-store |
| Worker access | Main thread only | Available in Web Workers |
Keep in mind that this is a capability gap, not a preference. localStorage remains the right tool for a settings flag or a session token; IndexedDB is what you reach for once the payload is large, binary, or performance-sensitive.
What IndexedDB Actually Is
Most developers meet IndexedDB expecting a key-value store and leave frustrated, because that mental model is a category error. It is closer to a small NoSQL database: you open a named database, define object stores — the rough equivalent of tables — and every operation runs inside a transaction.
Each object store holds records keyed by a primary key, and you can define secondary indexes to query by other fields. That means you can pull every save for world three with a cursor over an index, rather than deserializing one giant blob and filtering it in memory.
An object store is IndexedDB's equivalent of a table. It holds keyed records — plain objects, Blobs, or ArrayBuffers — and supports secondary indexes so you can query by fields like slot number or timestamp without loading the whole store.
The Three Things You Actually Store
Local persistence for a browser game breaks down into three payload shapes, and each wants its own object store. Separating them keeps your upgrade logic clean and your autosave writes cheap.
Save Slots
A save slot is a structured snapshot: player position, inventory, quest flags, RNG seed, and a version stamp. Store each slot as one record keyed by slot id, and add an index on an updatedAt timestamp so a continue button can find the most recent save in one cursor step.
Because IndexedDB uses the structured clone algorithm, you can persist nested objects, Maps, Sets, typed arrays, and Dates without hand-rolling serialization. That alone removes most of the JSON.stringify boilerplate that save systems accrete over time.
Settings and Progression
Settings are small, read often, and written rarely — audio levels, key bindings, graphics tier, accessibility toggles. Keep them in a dedicated store so a settings write never has to touch the multi-megabyte save record sitting next to it.
This is where the reader-facing detail matters: read settings once at boot into an in-memory object, and only write back on change. IndexedDB is fast, but the main-thread cost lives in the transaction plumbing, not the byte count.
Cached Assets
This is the payload localStorage simply cannot hold. Store decoded textures, audio buffers, level bundles, or downloaded sprite atlases as Blob or ArrayBuffer records, keyed by asset URL or content hash.
On the next boot you check the store before hitting the network, which turns a cold asset fetch into a local read. If you are already building a streaming layer, this pairs directly with the patterns in our guide to asset streaming for browser games.
Invalidate by content hash rather than by time. If an asset's hash changes you fetch and overwrite; if it matches, you serve the local copy — which sidesteps the stale-cache guessing that time-based expiry invites.
Rule of thumb: settings in one store, save slots in another, binary assets in a third. Three small stores upgrade and back up far more cleanly than one overloaded store that mixes JSON and blobs.
The Open, Upgrade, and Read Lifecycle
The shape of an IndexedDB session is always the same three beats: open the database at a version, handle any upgrade, then run transactions against the stores. Getting this sequence into a small wrapper early saves you from scattering event handlers across your codebase.
You open with indexedDB.open(name, version), which returns a request that fires onupgradeneeded when the version is new, then onsuccess once the database is ready. Every later read or write starts by calling transaction on that handle, naming the stores and the mode — readonly or readwrite.
Because the API is event-based, most teams wrap it in a thin promise layer so a read behaves like the rest of their async code. Keep the wrapper thin — the abstractions that hurt later are the ones that hide transactions and versions, the two things you most need to control.
Schema, Versions, and the Upgrade Trap
IndexedDB versions the whole database with an integer, and you can only create or delete object stores inside an onupgradeneeded event. Bump the version number, and the browser fires that event exactly once before any transaction runs — the only window where schema changes are legal.
The trap is treating the version as a throwaway. Pin it, migrate deliberately, and never assume a returning player is on your latest schema — they may be opening a database you defined eleven releases ago.
Bump the database version integer to trigger IndexedDB's onupgradeneeded event, the only place you can add or remove object stores and indexes. Write migrations that step through versions in order so a player on an old schema upgrades cleanly.
Version-pin your save format too, independent of the database version. A save record should carry its own schema tag so a migration can transform an old inventory shape into the current one, rather than crashing on a field that no longer exists.
Transactions, Idempotency, and the Autosave Loop
Every read and write in IndexedDB happens inside a transaction scoped to one or more stores, and that transaction either commits atomically or aborts whole. For an autosave that touches the save store and a metadata store together, atomicity is the feature — you never end up with a half-written save.
Design your autosave to be idempotent: writing the same snapshot twice should be harmless, so a retry after a transient abort cannot corrupt state. Key each save by slot id and overwrite in place with put, rather than accumulating append-only records you later have to reconcile.
Do not fire an autosave on every frame — debounce it. Mark state dirty during play and flush to IndexedDB on an interval or at natural checkpoints, which keeps write pressure off the loop that has to hold your fixed timestep steady.
Watch the transaction lifetime. An IndexedDB transaction auto-commits when it goes idle, so awaiting unrelated work mid-transaction can close it early and throw a TransactionInactiveError — keep the async work inside a transaction tight.
Reading and Writing From Workers
IndexedDB is one of the few storage APIs available inside Web Workers, and that is a gift for game architecture. You can move all persistence off the main thread and let the render loop stay focused on drawing frames.
Run your save and asset-cache logic in a worker, post snapshots to it over the message channel, and the disk I/O never touches the thread painting your canvas. This composes naturally with an OffscreenCanvas worker setup, where rendering and persistence both live off the main thread.
Yes — IndexedDB is accessible inside Web Workers, unlike localStorage. Running save and asset-cache writes in a worker keeps disk I/O off the main thread, so persistence never causes a frame hitch during active play.
Storage Limits, Eviction, and Persistence
IndexedDB quota is generous but neither infinite nor guaranteed. Browsers allocate storage per origin as a share of free disk — often on the order of gigabytes — but under storage pressure they can evict data classified as best-effort.
For a game that has cached assets or unsynced saves, call navigator.storage.persist() to request durable storage the browser will not silently evict. Pair it with navigator.storage.estimate() to read your usage and quota, and warn the player before a large asset cache would blow the budget.
Call navigator.storage.persist() to mark IndexedDB data durable so the browser will not evict it under disk pressure. Use navigator.storage.estimate() to check current usage and remaining quota before caching large assets.
Where Local Persistence Stops and Cloud Sync Begins
Everything above keeps state on one device, in one browser profile — which is exactly the boundary to be clear about. A player who switches from a laptop to a phone will not see their IndexedDB saves, because that data never left the machine that wrote it.
That is the line between local persistence and cloud sync, and the two are complementary rather than competing. IndexedDB gives you an instant, offline-capable local cache; a server round-trip gives you cross-device continuity, and the durable local copy is what lets you sync opportunistically instead of blocking play on the network.
If you need saves to follow the player across devices, treat IndexedDB as the write-through cache in front of a server, and see our deeper treatment of cross-platform save state for the reconciliation patterns. For the account layer those cloud saves hang off of, the cross-platform player identity guide covers the rest.
Frequently Asked Questions
Does IndexedDB data survive a browser restart?
Yes. IndexedDB persists to disk and survives refreshes, tab closes, and browser restarts. It is cleared only if the user wipes site data, the browser evicts non-persistent storage under disk pressure, or your code deletes it.
How much can IndexedDB store compared to localStorage?
localStorage caps near 5MB per origin. IndexedDB is disk-based and allocates a share of free space per origin — often hundreds of megabytes to several gigabytes — which is why it can hold cached textures and audio buffers.
Can I use IndexedDB inside a Web Worker?
Yes. IndexedDB is available in Web Workers, unlike localStorage. Running save and asset-cache writes in a worker keeps disk I/O off the main thread, so persistence never stalls the frame your render loop is drawing.
How do I stop the browser from evicting my saved data?
Call navigator.storage.persist() to request durable storage the browser will not evict under disk pressure. Pair it with navigator.storage.estimate() to check usage and quota before caching large assets.
Is IndexedDB a replacement for cloud save sync?
No. IndexedDB stores state on one device and browser profile, so it will not follow a player to another machine. Use it as an offline-capable local cache in front of a server that handles cross-device save sync.
Wire up your three object stores, pin your schema version, and move the writes into a worker, and a mid-session refresh stops being a data-loss event and becomes a non-event. That is the whole promise of local persistence done right.
For more browser-game engineering guides — from the render pipeline to input handling — browse the Simplified interactive-web hub and build the rest of your client stack on the same foundation.


