Need Game Art? Edit Game Trailers Launch Your Dev Site Sell Your Merch
Need Game Art? Sell Your Merch

IndexedDB for Web Game Storage

Updated July 2026
IndexedDB is the browser's most powerful storage API, offering asynchronous reads and writes, structured data storage, transaction safety, and capacity limits measured in hundreds of megabytes or more. For web games that outgrow LocalStorage's 5 MB limit or need non-blocking save operations, IndexedDB is the correct upgrade. Its API is more complex than LocalStorage, but wrapper libraries and a clear understanding of the core concepts make it practical for any game project.

Why IndexedDB Over LocalStorage

LocalStorage blocks the main thread on every read and write. For small saves this is invisible, but once save data exceeds 100 KB and saves happen every 30 seconds, the synchronous blocking causes noticeable frame drops. IndexedDB operates asynchronously through event callbacks or promises, meaning save operations run in the background without interrupting the game loop. The game continues rendering at full frame rate while data writes to disk.

Capacity is the other decisive advantage. LocalStorage caps at 5 MB per origin in most browsers. IndexedDB allows up to roughly 50% of available disk space per origin, which on modern devices translates to hundreds of megabytes or even multiple gigabytes. Games that store procedurally generated world data, user-created content, downloaded asset caches, or extensive replay data need this capacity. Even games with modest save sizes benefit from the headroom, because it eliminates the risk of hitting a storage wall as the game grows.

IndexedDB stores structured data natively. While LocalStorage stores only strings (requiring manual JSON serialization), IndexedDB stores JavaScript objects, arrays, dates, regular expressions, blobs, array buffers, and nested structures directly. This means you can store a game state object without calling JSON.stringify(), and IndexedDB will persist each field with its original type. You can also store binary data like screenshots, audio clips, or compressed save files as Blobs or ArrayBuffers without converting them to base64 strings.

Core Concepts

Database: An IndexedDB database is a named container that holds one or more object stores. A game typically creates a single database (e.g., "myGame") and uses different object stores for different data types. The database has a version number that increments whenever the schema changes, similar to how game save versions track data structure changes.

Object Store: An object store is analogous to a table in a relational database. It holds records (JavaScript objects) identified by keys. A game save system might have an object store called "saves" where each record represents a save slot, and a separate object store called "settings" for player preferences. You define the key path when creating the object store: keyPath: "slotId" means each record must have a slotId property that serves as its unique identifier.

Transaction: Every read and write in IndexedDB happens inside a transaction. Transactions can be "readonly" (for reading data) or "readwrite" (for reading and writing). A transaction groups operations atomically: if any operation in a readwrite transaction fails, all changes are rolled back. For game saves, this means a save that includes updating multiple object stores (game state plus metadata) either succeeds completely or fails completely, preventing partial saves.

Index: An index provides fast lookups on properties other than the primary key. For a leaderboard object store, you might index on "score" to quickly retrieve the top scores without scanning every record. Indexes are optional and should only be created for fields you actually query by, because they add overhead to write operations.

Opening a Database and Handling Upgrades

The indexedDB.open(name, version) call opens a database, creating it if it does not exist. The version number controls schema migrations. When the specified version is higher than the existing database version, the onupgradeneeded event fires, giving you the opportunity to create new object stores, add indexes, or modify the schema. This is the only time you can change the database structure.

Inside the onupgradeneeded handler, check the old version to determine which migrations to run. If upgrading from version 0 (first time), create all object stores. If upgrading from version 1 to version 2, add only the new object stores or indexes. This pattern parallels the save file versioning approach from LocalStorage, but operates at the database schema level rather than the data level. Keep upgrade code cumulative: a fresh install runs all upgrades from version 0 to the current version, and a returning player runs only the upgrades they missed.

A common mistake is trying to create an object store outside of the onupgradeneeded event. IndexedDB strictly enforces that schema changes happen only during version upgrades. If you need to add a new object store to an existing database, you must increment the version number and handle the creation in the upgrade handler. This can be surprising for developers accustomed to LocalStorage's schemaless flexibility, but it enforces data integrity that matters for long-lived games.

Reading and Writing Game Data

Writing a save to IndexedDB requires opening a readwrite transaction on the target object store, calling put() with the save data, and listening for the transaction's oncomplete event to confirm success. The put() method inserts a new record if the key does not exist, or replaces the existing record if it does. This upsert behavior is exactly what game saves need: the same save slot key overwrites the previous data every time.

Reading a save uses a readonly transaction with a get(key) call. The onsuccess event provides the result, which is the stored JavaScript object with all its original types preserved. If the key does not exist, the result is undefined. Unlike LocalStorage, there is no need to parse JSON because IndexedDB deserializes the object automatically.

The callback-based API is verbose, which is why most game projects wrap IndexedDB calls in promises. A simple wrapper function that returns a promise resolving with the result of a get() or rejecting on error reduces save/load code from 15 lines to 2. Libraries like idb (by Jake Archibald) provide a thin promise wrapper over IndexedDB that makes the API feel as clean as LocalStorage while preserving all the async and capacity benefits.

Storing Binary and Large Data

IndexedDB stores binary data natively as Blobs and ArrayBuffers. This opens possibilities that LocalStorage cannot match. A game can take a canvas screenshot on save and store it as a Blob alongside the save data, giving the save/load screen a visual preview of each slot. A game with large procedurally generated worlds can store chunk data as compressed ArrayBuffers, achieving far better storage efficiency than JSON strings.

To store a canvas screenshot: call canvas.toBlob() to produce a Blob, then store it in IndexedDB as a property of the save record or in a separate object store keyed to the slot ID. To display it later, create an object URL from the Blob using URL.createObjectURL(blob) and set it as the source of an image element. Revoke the object URL with URL.revokeObjectURL() when the image is no longer displayed to free memory.

Compressed game state is another practical use of binary storage. The Compression Streams API (supported in Chrome, Edge, Firefox, and Safari) lets you compress JSON strings into gzip format before storing them. A typical game state that serializes to 200 KB of JSON compresses to 30-50 KB as a gzip ArrayBuffer, saving significant storage space and reducing write times. Decompression on load adds a few milliseconds but is negligible compared to the storage and I/O savings.

Transaction Patterns for Games

Single-slot save: Open a readwrite transaction on the saves object store, call put(gameState), and wait for oncomplete. This is the simplest pattern and covers most auto-save and single-slot games.

Multi-store save: Open a single readwrite transaction that spans multiple object stores (e.g., "saves" and "metadata"). Write the game state to the saves store and the slot metadata (timestamp, level name, play time) to the metadata store. Because both writes are in the same transaction, they succeed or fail together. This prevents the metadata from showing a save that did not actually complete.

Bulk write: For games that save chunk data for procedural worlds, writing dozens or hundreds of records in a single transaction is far more efficient than opening a separate transaction for each chunk. IndexedDB batches the writes and commits them to disk once, reducing I/O overhead. Open a single readwrite transaction, call put() for each chunk in a loop, and wait for the transaction's oncomplete event at the end.

Read-then-write: When loading a save and immediately modifying it (e.g., incrementing a play counter or updating the load timestamp), use a single readwrite transaction. Call get() to read the save, modify the returned object in the onsuccess handler, and call put() with the modified object in the same transaction. The transaction ensures no other operation can modify the data between the read and the write.

Performance Characteristics

IndexedDB write performance varies by browser and data size. On desktop Chrome, writing a 100 KB object typically takes 2-5 milliseconds, and writing a 1 MB object takes 10-30 milliseconds. Because writes are asynchronous, these times do not block the game loop. The actual disk I/O happens in a background thread, and the game receives a callback when it completes. This means even large saves have zero impact on frame rate.

Read performance is similarly fast. Getting a single record by key typically completes in under 1 millisecond for records up to several megabytes. Scanning all records in an object store (using a cursor) scales linearly with the number of records. For a game with 3 save slots, reading all slot metadata to populate a save/load screen is effectively instant.

The initial indexedDB.open() call can be slow on the first visit (50-200 milliseconds) because the browser must create the database file on disk. On subsequent visits, opening an existing database is fast (under 10 milliseconds). For this reason, open the database as early as possible in the game's initialization sequence, not when the player first triggers a save. Keeping the database connection open for the entire session avoids repeated open/close overhead.

Storage eviction is the main durability concern. Without requesting persistent storage permission (via navigator.storage.persist()), IndexedDB data is stored in "best-effort" mode, meaning the browser can evict it under storage pressure. Calling persist() and receiving a true response grants persistent storage, which tells the browser not to evict the data without user action. Chrome grants persistence automatically to sites with high engagement. Firefox prompts the user. Safari does not support the Persistent Storage API. For maximum durability, pair IndexedDB with cloud saves.

Wrapper Libraries Worth Considering

idb by Jake Archibald is the most popular IndexedDB wrapper. It wraps the callback-based API in promises, making IndexedDB code readable and composable with async/await. The library adds less than 2 KB to your bundle and requires no configuration. For most game projects, idb is the right balance of convenience and control.

Dexie.js is a more full-featured wrapper that adds a query API, table definitions, and version management on top of IndexedDB. It is larger (around 20 KB minified) but simplifies complex queries and schema migrations. If your game has multiple object stores with indexes and needs to query data in flexible ways (e.g., "find all saves where level is greater than 5"), Dexie reduces boilerplate significantly.

localForage provides a LocalStorage-compatible API backed by IndexedDB (with fallbacks to WebSQL and LocalStorage). If you want the simplicity of setItem/getItem with the capacity and async benefits of IndexedDB, localForage is a zero-effort upgrade. It does not expose IndexedDB-specific features like transactions and indexes, but for simple save systems, those features are unnecessary.

Key Takeaway

IndexedDB is the right storage API for web games with save data exceeding 100 KB, frequent auto-saves, binary data needs, or transactional integrity requirements. Use a wrapper library like idb or localForage to simplify the API, open the database connection at startup, and request persistent storage to protect against browser eviction.