Area: Browser storage
Related: Browser APIs (map), localStorage, OPFS (Origin Private File System), SQLite in the browser (WASM), Web Locks API, BroadcastChannel, Yjs

What is it?

IndexedDB is the real database built into every browser. It stores JavaScript objects, not just strings, and it can hold hundreds of megabytes or more.

It is not a SQL database. There are no tables, no joins and no query language. It is closer to a sorted key-value store with secondary indexes and transactions on top.

A set of sorted maps. Each map keeps its records in key order. You can add extra sorted views (indexes) over a field. Every read and write happens inside a transaction.

The name says what makes it different from localStorage: the data is indexed. You can ask for “every record where createdAt is between Monday and Friday” without loading everything and filtering it yourself.

Why it exists

localStorage was the first attempt at app storage, and it has three problems:

  1. It is synchronous. Every getItem blocks the main thread while the browser reads from disk.
  2. It only stores strings. You JSON.stringify everything, so a Date becomes a string, and a Blob doesn’t work at all.
  3. It is small, around 5 MB per origin.

Before IndexedDB, browsers shipped WebSQL, which was real SQLite exposed to JavaScript. Mozilla refused to implement it. The spec would have been “whatever SQLite version 3.6.19 does”, and a web standard can’t be defined by one library’s behaviour. WebSQL was dropped. IndexedDB was designed as a lower-level building block instead: a transactional object store that libraries could build richer things on top of.

That history explains the API. It feels low level because it was meant to be low level.

The mental model

origin (https://example.com)
└── database "notes-app" (version 3)
    ├── object store "notes"        key: id
    │   ├── index "byFolder"        on folderId
    │   └── index "byUpdated"       on updatedAt
    └── object store "settings"     key: out-of-line string
  • Origin. Each origin gets its own set of databases. https://a.com cannot read https://b.com’s data.
  • Database. Has a name and an integer version. The schema (which stores and indexes exist) can only change when the version goes up.
  • Object store. Like a table, but it holds whole objects. Every record has a key, and records are always kept sorted by key.
  • Index. A second sorted view of a store, ordered by one field (or several) of the stored objects. The browser keeps it up to date for you on every write.
  • Transaction. Every read or write happens inside one. It decides which stores you can touch and whether you can write.
  • Request. Each operation (get, put, delete) returns a request object. The result arrives later as an event.

If you’ve used a sorted map (a B-tree, a TreeMap in Java), you already know most of it. A store is a sorted map. An index is another sorted map from “field value” to “primary key”.

Opening a database and the version dance

const request = indexedDB.open("notes-app", 3);
 
request.onupgradeneeded = (event) => {
  const db = request.result;
  const oldVersion = event.oldVersion; // 0 if the database is brand new
 
  if (oldVersion < 1) {
    const notes = db.createObjectStore("notes", { keyPath: "id" });
    notes.createIndex("byFolder", "folderId");
  }
  if (oldVersion < 2) {
    db.createObjectStore("settings"); // no keyPath: you pass the key yourself
  }
  if (oldVersion < 3) {
    const notes = request.transaction!.objectStore("notes");
    notes.createIndex("byUpdated", "updatedAt");
  }
};
 
request.onsuccess = () => {
  const db = request.result;
  // ready to use
};
 
request.onerror = () => console.error(request.error);

Things to notice:

  • onupgradeneeded is the only place you can create or delete stores and indexes. Outside of it, createObjectStore throws.
  • Write migrations as a ladder (if (oldVersion < 1), if (oldVersion < 2), …), not a switch on the current version. A user might jump from version 1 straight to version 3 and needs every step in between.
  • Inside the upgrade you are already in a special versionchange transaction. Reach it through request.transaction when you need to touch an existing store, like adding an index in step 3.
  • The version must be a whole number that only goes up. Opening with a lower version than what’s on disk fails with a VersionError. That matters when you roll back a deploy: old code can’t open a database that new code already upgraded.

The multi-tab problem with versions

Say tab A is open with version 2. You deploy, and tab B loads the new code and asks for version 3.

  1. Tab A gets a versionchange event on its open connection.
  2. If tab A doesn’t close its connection, tab B’s open request gets a blocked event and just waits.
  3. Tab B’s upgrade only runs once every old connection is closed.

So every app should do this:

db.onversionchange = () => {
  db.close();
  // optionally tell the user: "A new version is available, please reload"
};

Forget this and a user with an old tab open can leave a new tab stuck forever on a loading spinner. It is one of the most common IndexedDB bugs in production.

Keys

Every record has a key, and keys decide the sort order. There are two ways to give a record its key.

SetupHow the key is foundExample
keyPath: "id" (in-line key)Read from the objectstore.put({ id: "n1", title: "Hi" })
no keyPath (out-of-line key)You pass it separatelystore.put(value, "theme")
autoIncrement: trueThe browser makes one (1, 2, 3…)append-only logs

keyPath can be nested ("meta.id") or an array (["folderId", "id"]) for a compound key.

What can be a key

Only these types are valid keys:

  • numbers (but not NaN)
  • strings
  • Date objects (but not invalid dates)
  • binary data (ArrayBuffer, typed arrays)
  • arrays made of valid keys

Not valid: true/false, null, undefined, plain objects. Booleans catch people out all the time. You can’t index on an isArchived: true field. Store 0/1 instead, or keep archived records in a separate store.

How keys sort

Different types sort in a fixed order, then by value inside each type:

numbers < dates < strings < binary < arrays

So 5 comes before "5", and any string comes before any array. Strings compare by UTF-16 code unit, not by locale. "Z" sorts before "a", and "é" sorts after "z". If you want case-insensitive or locale-aware order, store a normalised copy of the field (titleLower) and index that.

Arrays compare element by element, like words in a dictionary. That is what makes compound keys useful:

["folderA", 1]
["folderA", 2]
["folderA", 10]
["folderB", 1]

All of folder A’s records sit together in key order. More on that under ranges.

Values: the structured clone algorithm

Values are copied in with the structured clone algorithm, the same one postMessage and structuredClone() use. It is much richer than JSON.

Survives the round trip:

  • plain objects and arrays
  • Date (comes back as a real Date)
  • Map and Set
  • ArrayBuffer, typed arrays, DataView
  • Blob and File
  • RegExp
  • Error objects in modern browsers
  • circular references

Does not survive:

  • functions → throws DataCloneError
  • DOM nodes → throws
  • class instances lose their class. A new Note(...) comes back as a plain object with the same fields, and its methods are gone.
  • getters, setters and property descriptors
  • Symbol keys

Two practical results:

  1. You store data, never behaviour. Keep a function like toNote(raw) that turns stored objects back into class instances, if you use classes.
  2. Cloning happens on the thread that calls put. A 20 MB object is copied before the write even starts. On the main thread that can drop frames. Big writes belong in a Web Worker, which can use IndexedDB too.

Storing a Blob is the cheap way to keep files, images and audio. The browser usually writes large blobs to their own file on disk instead of copying them into the database, and reading one back gives you a Blob you can URL.createObjectURL(). Store blobs rather than base64 strings. Base64 is about a third bigger and has to be decoded.

Indexes

notes.createIndex("byFolder", "folderId");
notes.createIndex("byFolderUpdated", ["folderId", "updatedAt"]);
notes.createIndex("byTag", "tags", { multiEntry: true });
notes.createIndex("bySlug", "slug", { unique: true });
  • An index maps index key → primary key, kept sorted by index key.
  • unique: true makes a write fail if another record already has the same value. That is the only kind of constraint IndexedDB has.
  • multiEntry: true is for array fields. A note with tags: ["work", "urgent"] gets two entries in the index, one per tag. Now “all notes tagged work” is a single lookup. Without multiEntry, the index key would be the whole array ["work", "urgent"], which is almost never what you want.
  • You can’t combine multiEntry with an array keyPath.

The rule that silently loses data from queries

A record is left out of an index if the index’s keyPath doesn’t give a valid key.

So if a note has no folderId, or folderId: null, or folderId: true, it simply isn’t in byFolder. There’s no error. Querying the index never returns that note.

Sometimes that’s useful. Make an index on deletedAt, and only deleted notes appear in it: a free “trash” view. Mostly, though, it is a bug you find months later. When a query looks like it’s missing rows, check the field type first.

Transactions

const tx = db.transaction(["notes", "settings"], "readwrite");
const notes = tx.objectStore("notes");
notes.put({ id: "n1", title: "Hello", folderId: "f1", updatedAt: Date.now() });
 
tx.oncomplete = () => console.log("saved");
tx.onerror = () => console.error(tx.error);
tx.onabort = () => console.warn("rolled back", tx.error);

Three modes:

ModeCan doRuns alongside
readonlyreadsother readonly transactions on the same stores
readwritereads and writesnothing that overlaps its stores
versionchangeanything, including schemanothing at all (only inside onupgradeneeded)

The scope (the list of stores) matters. Two readwrite transactions on notes run one after the other. A readwrite on notes and one on settings can run at the same time. Ask for the smallest scope you need.

Transactions are all or nothing. If any request fails and you don’t handle it, the whole transaction rolls back, including writes that already “succeeded”.

Auto-commit: the rule that bites everyone

You never call commit in normal code. A transaction commits by itself as soon as two things are true:

  1. it has no requests still pending, and
  2. the code that queued them has returned to the event loop.

In practice, the transaction stays alive while you’re inside one of its request callbacks, or in the same synchronous run of code. It also survives awaiting promises that resolve right away as microtasks. It dies as soon as you wait on anything that takes a real trip through the event loop: fetch, setTimeout, a user click, a message from a worker.

// ❌ Broken
const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
const remote = await fetch("/api/note/1").then((r) => r.json()); // tx commits here, empty
store.put(remote); // throws TransactionInactiveError
// ✅ Do the slow thing first, then open the transaction
const remote = await fetch("/api/note/1").then((r) => r.json());
const tx = db.transaction("notes", "readwrite");
tx.objectStore("notes").put(remote);

Why is it designed like this? A transaction holds locks on its stores. If it could stay open across network calls, one slow request could freeze every other tab’s writes to the same store. Auto-commit means a forgotten transaction can never hold a lock forever.

The cost is that you can’t do “read, call the server, then write” atomically. If you need that, read in one transaction, do the slow work, then write in a second transaction and re-check that nothing changed in between (compare a version number or updatedAt). That is optimistic concurrency, the same idea as ETags on HTTP.

Error handling inside a transaction

Errors bubble: request → transaction → database.

const req = store.add({ id: "n1" }); // add() fails if the key already exists
req.onerror = (event) => {
  event.preventDefault(); // "I handled it, don't abort the transaction"
  event.stopPropagation();
};

Without preventDefault(), one failed add rolls back everything else in that transaction. Sometimes that’s what you want. Make it a deliberate choice.

put vs add: put inserts or overwrites. add only inserts and fails with ConstraintError if the key already exists.

Durability

Since around 2020 you can pass a durability hint:

db.transaction("cache", "readwrite", { durability: "relaxed" });
  • strict: complete fires only after the data is flushed to disk. Safer against a power cut, but slower, since an fsync can take milliseconds.
  • relaxed: complete fires once the OS has the write, without forcing a flush. Much faster for many small writes, and a crash might lose the last moment of writes.
  • default: the browser decides.

Browsers have picked different defaults over the years. When it matters, set the hint explicitly. Use relaxed for caches you can rebuild. Use strict for a user’s unsent edits.

There’s also tx.commit(), which says “I’m done, don’t wait for auto-commit”. It is mainly useful to make a transaction finish sooner, not for correctness.

The request and event API (and why everyone wraps it)

The raw API predates promises. Every call returns an IDBRequest, and you listen for success or error. Two operations in a row turn into nested callbacks fast.

A tiny promise wrapper gets you most of the way:

function promisify<T>(req: IDBRequest<T>): Promise<T> {
  return new Promise((resolve, reject) => {
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}
 
function txDone(tx: IDBTransaction): Promise<void> {
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error ?? new DOMException("Aborted", "AbortError"));
  });
}
 
// usage
const tx = db.transaction("notes", "readonly");
const note = await promisify(tx.objectStore("notes").get("n1"));

This works because each promise resolves in a microtask right after the success event, while the transaction is still active. Don’t put a real await on anything else between steps.

Important: a request’s success doesn’t mean the data is saved. A later request in the same transaction can still fail and roll everything back. The write is durable only when the transaction’s complete fires. Await txDone(tx) before telling the user “saved”.

Libraries people actually use:

  • idb (Jake Archibald): the raw API with promises and nothing else. About 1 KB. Best place to start.
  • idb-keyval: just get/set/del on one store. Good when you need a bigger, async localStorage.
  • Dexie.js: a full query layer with a fluent API, compound queries, live queries that re-run on change, and schema versions written as strings. Good for apps with real data models.
  • y-indexeddb, RxDB, PouchDB: higher-level sync and replication systems built on IndexedDB.

Reading data: get, getAll, ranges and cursors

Single lookups

store.get("n1");             // by primary key
store.getKey(range);         // first matching key only
store.count();               // how many records
store.index("bySlug").get("hello-world"); // through an index → the full record

Key ranges

IDBKeyRange describes a slice of the sorted keys:

IDBKeyRange.only("f1");                  // exactly f1
IDBKeyRange.lowerBound(100);             // >= 100
IDBKeyRange.lowerBound(100, true);       // > 100 (true = open, excludes the bound)
IDBKeyRange.upperBound(200);             // <= 200
IDBKeyRange.bound(100, 200, false, true); // 100 <= key < 200

Since everything is sorted, a range query is one seek to the start, then a walk forward. It’s fast no matter how big the store is.

Two tricks that unlock most queries

Prefix search on strings. “Every title starting with rea”:

IDBKeyRange.bound("rea", "rea￿");

￿ is the highest character in a UTF-16 string, so anything starting with rea sorts between those two bounds.

Equality plus range with a compound index. “Notes in folder f1, updated this week, newest first”:

// index "byFolderUpdated" on ["folderId", "updatedAt"]
const range = IDBKeyRange.bound(["f1", weekStart], ["f1", Infinity]);
index.openCursor(range, "prev");

Everything in folder f1 sits next to each other, sorted by updatedAt inside. The equality part has to come first in the compound key, and the range part last. That’s the same rule as composite indexes in SQL databases, for the same reason.

To get “everything in folder f1” from a compound index, use [] as the upper end, since arrays sort after every other type:

IDBKeyRange.bound(["f1"], ["f1", []]);

getAll vs cursors

await promisify(store.getAll(range, 50));     // up to 50 values
await promisify(store.getAllKeys(range, 50)); // just the keys, much cheaper

getAll is one round trip and is the fastest way to read many records. Its limits: it only goes forward, it can’t skip, and it loads everything into memory at once.

A cursor walks the range one record at a time:

const req = index.openCursor(range, "prev"); // "next" | "prev" | "nextunique" | "prevunique"
req.onsuccess = () => {
  const cursor = req.result;
  if (!cursor) return; // finished
  console.log(cursor.key, cursor.primaryKey, cursor.value);
  cursor.continue();
};

Cursor moves:

  • cursor.continue(): next record
  • cursor.continue(key): jump forward to the first record at or after key
  • cursor.advance(n): skip n records
  • cursor.update(value) / cursor.delete(): edit in place (readwrite only)
  • openKeyCursor(): walks keys without loading values. Use it when you only need ids.

nextunique gives one entry per distinct index value. On a byFolder index that turns into “list every folder that has notes”.

Each continue() is a separate round trip. Walking 10,000 records with a cursor is much slower than getAll. Use a cursor when you need to stop early, edit as you go, or skip around with continue(key).

Pagination

Don’t paginate with advance(page * size). It still walks every skipped record. Remember the last key you saw and start the next page just after it:

async function page(index: IDBIndex, after: IDBValidKey | null, size: number) {
  const range = after === null ? null : IDBKeyRange.lowerBound(after, true);
  return promisify(index.getAll(range, size));
}

On a non-unique index, the “last key” has to include the primary key too, or you’ll skip records that share the same index value. That’s another reason compound indexes like ["updatedAt", "id"] show up a lot.

What you can’t query (and what to do instead)

IndexedDB answers exactly one kind of question: “give me the records in this key range of this one index.” Everything else is your job.

You wantWhat it takes
WHERE a = 1 AND b = 2A compound index on [a, b]
WHERE a = 1 OR a = 2Two queries, then merge in JS
WHERE a = 1 AND b > 5Compound index [a, b] with a range on the second part
WHERE b > 5 AND c > 9Query the more selective one, filter the rest in JS
ORDER BY some other fieldAn index on that field, or sort in memory
JOINFetch both sides, join in a Map
COUNT ... GROUP BYWalk with a nextunique cursor plus count() per key, or keep counters yourself
Full-text searchBuild your own inverted index (word → ids) in a multiEntry index, or use a library like FlexSearch or MiniSearch
LIKE '%foo%'Not possible with an index. Scan everything

So you design indexes around your queries, not around your data. Write down the screens of your app and the exact queries each one needs, then create the indexes for those queries. That’s the same thinking as DynamoDB or other NoSQL stores. If you come from SQL, this is the biggest shift.

When the questions you need to answer look like real SQL (several joins, grouping, ad-hoc filters), that’s the sign you’ve outgrown IndexedDB and should look at SQLite in the browser (WASM).

Where the data actually lives

Every browser implements IndexedDB on top of another storage engine:

  • Chrome / Edge / other Chromium browsers: LevelDB, a log-structured key-value store from Google. Large values and blobs go into separate files next to it.
  • Firefox: SQLite. Each origin’s databases are SQLite files in the profile folder.
  • Safari / WebKit: also SQLite.

So the “Notion uses SQLite, others use IndexedDB” debate is a bit funny. In two out of three browser engines, IndexedDB is SQLite underneath, just hidden behind a key-value API. Apps that ship WASM SQLite aren’t getting a better disk engine. They’re getting SQL as the query language, and they control the schema and file format themselves.

Each request also crosses a process boundary. In Chrome, your page talks to a separate storage service over IPC. That’s the main reason many small requests are slow and a few big ones are fast.

Performance: what actually matters

  1. Batch writes into one transaction. 1,000 puts in one transaction is many times faster than 1,000 transactions with one put each. Each commit has a fixed cost, and with strict durability that cost includes a disk flush.
  2. Use getAll / getAllKeys for bulk reads. One round trip instead of one per record.
  3. Read keys when you don’t need values. getAllKeys and openKeyCursor skip deserialising objects.
  4. Keep records reasonably small and split hot from cold data. If a list view only needs id, title and updatedAt, don’t make it load the 2 MB body every time. Put summaries in one store and bodies in another, both keyed by the same id.
  5. Big structured clones block the thread that does them. Move heavy writes to a worker.
  6. Indexes cost something on every write. Each put updates every index on that store. Don’t add indexes “just in case”.
  7. Use relaxed durability for caches. Rebuildable data doesn’t need an fsync.
  8. Open the database once and reuse the connection. Opening is slow, especially the very first time on Safari.

Quotas, eviction and “where did my data go?”

IndexedDB space comes out of the origin’s shared quota, together with the Cache API, OPFS and the rest.

const { usage, quota } = await navigator.storage.estimate();
const persisted = await navigator.storage.persist(); // ask to be "persistent"
  • Best-effort storage (the default). When the disk runs low, the browser may delete the whole origin’s storage, least recently used origins first. It never deletes half of your data. It’s everything or nothing for that origin.
  • Persistent storage. After navigator.storage.persist() succeeds, the browser won’t evict you on its own. Chrome grants it based on how engaged the user is with the site (bookmarked, installed, and so on). Firefox asks the user.
  • Quota sizes differ between browsers and have changed over the years. Chromium allows a large share of free disk per origin, Firefox has its own limits, and Safari has historically been the strictest. Treat the numbers as “large but not unlimited”, and handle a full disk.
  • Safari’s 7-day rule. Under Intelligent Tracking Prevention, Safari deletes script-writable storage (IndexedDB included) for a site the user hasn’t interacted with in 7 days of browser use. Installed home-screen web apps are exempt. If your app is used weekly in Safari, plan for the cache to vanish.
  • Private browsing. Modern browsers support IndexedDB in private windows, but it lives in memory and disappears when the window closes.

When you run out of space, writes fail with QuotaExceededError, usually at commit time, so it shows up as the transaction aborting. Catch it, clear old cache entries, and retry. Never let it break the app.

The big lesson: IndexedDB is a cache unless you’ve proven otherwise. Anything that only exists in IndexedDB can disappear without warning. Data that matters has to reach a server, or at least be in a persistent origin with the user told about the risk.

Many tabs, one database

All tabs of the same origin share the same databases. That’s convenient, and it causes three issues.

  1. No change events. If tab A writes a note, tab B isn’t told. Libraries like Dexie fake it. You can do it yourself by posting a message on a BroadcastChannel after each committed write, and having other tabs re-read.
  2. Upgrades get blocked by old tabs (see the version section above).
  3. Logical races. Transactions make each step atomic, but “read the counter in one transaction, write counter + 1 in another” can still lose an update if two tabs interleave. Do the read and write inside one readwrite transaction, or coordinate with the Web Locks API:
await navigator.locks.request("sync-queue", async () => {
  // only one tab at a time runs this, across the whole origin
});

Web Locks are also how you get “only one tab does the background sync” or “this per-tab cache belongs to the tab that’s holding the lock”.

Storage partitioning (iframes)

An iframe from https://app.com embedded inside https://teams.microsoft.com does not see the same IndexedDB as https://app.com opened directly. Browsers now key third-party storage by (top-level site, origin). The same app embedded in two different hosts has two separate databases. That matters for anything that runs inside another product’s iframe: caches don’t carry over between “opened directly” and “opened inside the host”.

Browser quirks worth knowing about

  • Safari has the worst bug history. A notable one: in Safari 14.1, indexedDB.open could hang forever on first page load until something poked it. Jake Archibald published a small workaround package for it. Another: in early 2022, indexedDB.databases() in Safari 15 leaked database names across sites, which let trackers see other sites you’d visited. Treat Safari as the browser to test first.
  • indexedDB.databases() (list all databases) arrived late in Firefox. Older code avoided it.
  • Errors without detail. Many failures show up as UnknownError or an aborted transaction with no clear reason, often caused by disk problems or a corrupted profile. Always have a “delete the database and rebuild from the server” fallback for cache data.
  • DevTools doesn’t refresh live. In Chrome’s Application → IndexedDB panel, you have to press refresh to see new writes. It has fooled plenty of people into thinking their writes failed.

A complete small example

A notes cache with summaries and bodies split apart, a folder index, and a “recent notes” query.

type NoteSummary = { id: string; folderId: string; title: string; updatedAt: number };
type NoteBody = { id: string; markdown: string };
 
let dbPromise: Promise<IDBDatabase> | null = null;
 
function openDb(): Promise<IDBDatabase> {
  if (dbPromise) return dbPromise; // open once, reuse
 
  dbPromise = new Promise((resolve, reject) => {
    const req = indexedDB.open("notes-cache", 1);
 
    req.onupgradeneeded = () => {
      const db = req.result;
      const summaries = db.createObjectStore("summaries", { keyPath: "id" });
      summaries.createIndex("byFolderUpdated", ["folderId", "updatedAt"]);
      summaries.createIndex("byUpdated", "updatedAt");
      db.createObjectStore("bodies", { keyPath: "id" });
    };
 
    req.onsuccess = () => {
      const db = req.result;
      db.onversionchange = () => {
        db.close();
        dbPromise = null;
      };
      resolve(db);
    };
    req.onerror = () => reject(req.error);
    req.onblocked = () => console.warn("Close other tabs to finish updating");
  });
 
  return dbPromise;
}
 
async function saveNote(summary: NoteSummary, body: NoteBody) {
  const db = await openDb();
  const tx = db.transaction(["summaries", "bodies"], "readwrite");
  tx.objectStore("summaries").put(summary);
  tx.objectStore("bodies").put(body);
  await txDone(tx); // only now is it really saved, both or neither
}
 
async function recentInFolder(folderId: string, limit = 20): Promise<NoteSummary[]> {
  const db = await openDb();
  const index = db
    .transaction("summaries", "readonly")
    .objectStore("summaries")
    .index("byFolderUpdated");
 
  const range = IDBKeyRange.bound([folderId, -Infinity], [folderId, Infinity]);
  const results: NoteSummary[] = [];
 
  return new Promise((resolve, reject) => {
    const req = index.openCursor(range, "prev"); // newest first
    req.onsuccess = () => {
      const cursor = req.result;
      if (!cursor || results.length >= limit) return resolve(results);
      results.push(cursor.value);
      cursor.continue();
    };
    req.onerror = () => reject(req.error);
  });
}

Why a cursor here and not getAll? getAll only goes forward, and we want newest first with a limit. With "prev" we read exactly limit records and stop.

Why two stores? The folder list only needs summaries. Opening a folder with 500 notes shouldn’t drag 500 markdown bodies into memory.

Why one transaction in saveNote? So you can never end up with a summary that points to a missing body after a crash.

Real-world pattern: Yjs + y-indexeddb

Yjs is a CRDT library for collaborative editing. Its IndexedDB provider is a neat example of IndexedDB used as an append-only log:

  • One database per document.
  • An updates store with autoIncrement keys. Every edit is appended as a small binary blob (a Uint8Array).
  • When the document loads, it reads every update in key order and applies them.
  • Once there are too many small updates, it merges them into one compacted update and deletes the old ones, in a single transaction. That’s the same idea as compaction in LevelDB, Kafka or any log-structured store.
  • A small custom store holds extra key-value metadata.

Nothing in there needs a query. It’s “append” and “read everything in order”, which is exactly what sorted keys plus autoIncrement give you for free.

IndexedDB vs the other browser storage options

SizeAPIStoresBest for
localStorage~5 MBsyncstringstiny settings, flags
sessionStorage~5 MBsyncstringsper-tab, gone on close
Cookies~4 KB eachsyncstringssent to the server on every request; auth
IndexedDBlargeasyncstructured objects, blobsapp data, caches, offline queues
Cache APIlargeasyncRequest → ResponseHTTP responses, service worker offline assets
OPFS (Origin Private File System)largeasync (sync in workers)files and bytesraw files, WASM databases

OPFS is the important newer one. It gives each origin a private file system, and inside a dedicated worker you can get a synchronous file handle with very fast random reads and writes. That’s what made SQLite in the browser (WASM) practical: SQLite compiled to WebAssembly, writing its database file to OPFS.

When to pick SQLite in WASM instead

Notion’s web app moved its local cache to WASM SQLite on OPFS and reported faster page navigation. Their desktop and mobile apps already used native SQLite, so it also meant one schema everywhere. It wasn’t free. OPFS sync handles allow one writer at a time, so with many tabs open they had to route all database work through a single tab, coordinated with a SharedWorker and Web Locks. Early on they hit database corruption from multiple tabs writing at the same time.

Pick IndexedDB when:

  • your data is looked up by key or a few well-known ranges (documents, blobs, logs, caches)
  • you want zero extra bundle size and support everywhere, including webviews and embedded iframes
  • many tabs need to read and write without a coordinator

Pick WASM SQLite on OPFS when:

  • you need real queries: joins, group-by, ad-hoc filters, full-text search with FTS5
  • you share a schema with native apps that already use SQLite
  • you can pay for about a megabyte of WASM, worker startup, and a single-writer setup across tabs

A good rule: if you catch yourself writing a query planner in JavaScript (merging index scans, joining in Maps, keeping counters by hand), you wanted SQL.

Gotchas (the short list)

  • Awaiting a fetch or setTimeout inside a transaction kills it → TransactionInactiveError.
  • A request’s success isn’t a save. Wait for the transaction’s complete.
  • Records whose index field is missing, null or a boolean are silently left out of that index.
  • Booleans can’t be keys. Use 0/1.
  • Strings sort by code unit, not alphabetically. Store a lowercase copy for case-insensitive order.
  • Always handle onversionchange by closing the connection, or new tabs get stuck on blocked.
  • Migrations are a ladder of if (oldVersion < n), and you can never open with a lower version.
  • Class instances come back as plain objects. Functions throw.
  • One failed request aborts the whole transaction unless you preventDefault().
  • Data can be evicted. IndexedDB is a cache until the data reaches a server.
  • Iframes get partitioned storage, separate from the same origin opened directly.
  • Safari first when testing.

Practice

  1. Key-value store. Build get/set/del over one store using only the raw API and the promisify helper. Then compare your code with idb-keyval’s source. It’s short and worth reading.
  2. Migration ladder. Ship version 1 with a notes store. Add an index in version 2. Rename a field in version 3 by walking a cursor inside onupgradeneeded and calling cursor.update. Open two tabs, upgrade one, and watch versionchange and blocked fire.
  3. Break a transaction on purpose. Put an await fetch(...) between two puts and read the error. Then fix it.
  4. Tag search. Store notes with a tags array, add a multiEntry index, and query “notes tagged X”. Then try “tagged X and Y” and notice you have to intersect two result sets yourself.
  5. Prefix search. Search titles with the ￿ trick on a lowercased title index.
  6. Pagination. Page through 10,000 records by last-seen key. Time it against advance().
  7. Bulk write benchmark. Insert 10,000 records with one transaction, then with 10,000 transactions, with relaxed and strict durability. Write down the numbers.
  8. Cross-tab sync. Two tabs show the same list. Write in one and use a BroadcastChannel to make the other re-read.
  9. Quota. Log navigator.storage.estimate() before and after storing a 50 MB Blob. Call persist() and see what each browser does.

Sources worth reading

  • MDN: Using IndexedDB, IDBKeyRange, Storage quotas and eviction criteria
  • W3C spec: Indexed Database API 3.0 (the transaction lifetime section is the clearest explanation of auto-commit)
  • Jake Archibald: the idb and idb-keyval READMEs and source
  • web.dev: Storage for the web, Persistent storage
  • Notion engineering blog: How we sped up Notion in the browser with WASM SQLite
  • Yjs docs: y-indexeddb