IndexedDB is not a faster HTTP cache. It is an application database that can be used as a cache when you need metadata, indexes, versioning, or queryable offline data. If that is not what you need, the browser’s resource cache and the Cache API are usually the better tool, with less code and better HTTP semantics.

This is the companion piece to HTTP Cache vs localStorage. That post looks at browser-managed resource caching and synchronous storage; this one continues the decision tree into IndexedDB, Cache API, and OPFS.

This post is the long version of that claim. The numbers come from a prototype I ran in shipping Chrome, Firefox, and Safari across macOS, Windows 11, and Arch Linux: seven browser/OS combinations covering 17 standardized presets. The full export bundles (with user agent, hardware specs, and storage estimates per run) live in the companion repo. Your exact timings will differ, but the pattern is what matters: which API shape is consistently faster, which payload shape hurts, and which browser/OS combinations deserve extra testing.

HTTP cache is resource storage governed by HTTP semantics. localStorage is a synchronous string key/value store that blocks the main thread. IndexedDB sits somewhere else entirely: a transactional object database that stores values via the structured clone algorithm, supports indexes, and is accessible from workers.

That makes IndexedDB attractive when the cache needs application logic: API caches with custom invalidation, offline-queryable datasets, binary payloads with metadata, or join-with-local-state patterns. It makes it a poor fit for things the browser already handles well: static assets with normal HTTP caching, tiny config reads on the critical path, auth tokens, or media playback that depends on range requests.

The mental model: IndexedDB is a database that can power a cache, not a cache protocol.

A common framing mistake is comparing the happy path of one API against the worst path of another. A hot localStorage.getItem() will always win a microbenchmark against a cold IndexedDB read; they are not doing the same job. IndexedDB has multiple performance profiles: open, schema upgrade, batched write, single write, key read, index read, cursor scan, getAll, getAllRecords, Blob versus ArrayBuffer, main thread versus worker. A single number across all of them is meaningless.

IndexedDB stores data per origin, and in modern browsers shares quota with Cache API and OPFS. Quotas are large but best-effort and managed by browser-specific policy:

  • Chromium allows up to ~60% of total disk per origin, reported coarsely via navigator.storage.estimate().
  • Firefox uses group/origin rules with larger limits for persistent storage.
  • WebKit’s 2023 storage policy update raised Safari’s per-origin limit to ~60% in browser apps and ~15% in embedded WebViews.
  • Private browsing modes clear best-effort storage at end of session.

A successful navigator.storage.estimate() does not guarantee your next write succeeds. Always handle QuotaExceededError on write paths.

Eviction is the part most people miss. When the browser reclaims best-effort storage under pressure, it does not gently delete one old IndexedDB record; it can wipe the origin’s stored data as a whole. For rebuild-from-network cache data that is annoying. For user-created data that was never synced it is dangerous. The rule:

  • Rebuildable cache data → IndexedDB is fine, eviction is acceptable.
  • User-created data not synced anywhere → request persistent storage and design a recovery path anyway.
  • Security-sensitive data → storage mechanism and token security are separate questions; do not pick IndexedDB just because it is async and bigger than localStorage.

WebKit applies a separate constraint on top of quota: script-writable storage (IndexedDB, Cache API, service workers, localStorage) is cleared after seven days of browser use without user interaction with the site. The clock counts days the browser was used; days with no browser use are skipped.

How browser use without a site visit contributes to script-writable storage deletion in WebKit

  1. Day 10

    Site visited. IndexedDB data is written. Counter starts.

  2. Day 2+1

    Browser used but no visit to the site.

  3. Day 3+2

    Browser used but no visit to the site.

  4. Day 4hold

    Browser not used. Counter does not advance.

  5. Day 5+3

    Browser used but no visit to the site.

  6. Day 6+4

    Browser used but no visit to the site.

  7. Day 7hold

    Browser not used. Counter does not advance.

  8. Day 8+5

    Browser used but no visit to the site.

  9. Day 9+6

    Browser used but no visit to the site.

  10. Day 10+7 wipe

    Counter reaches 7. All script-writable storage is deleted.

Site visited, storage writtenBrowser used, no site visit (+1)Browser not used (counter holds)Counter hits 7, storage cleared
Only days the browser was used without a visit to your site advance the counter. Days the user did not open the browser at all are skipped. Once the counter reaches seven, WebKit deletes IndexedDB, Cache API, service workers, and localStorage for that origin.

If your product depends on long-lived offline storage on iOS, test it on a real device with the actual usage pattern your users will have, not against an idealized day counter.

Transactions are the dominant factor

Permalink to "Transactions are the dominant factor"

IndexedDB work happens inside transactions. A single readwrite transaction that writes N records is a fundamentally different workload from N single-record transactions. The first lets the browser batch and commit once; the second pays coordination and durability overhead N times.

Bad:

for (const item of items) {
  const tx = db.transaction("responses", "readwrite");
  tx.objectStore("responses").put(item);
  await transactionDone(tx);
}

Better:

const tx = db.transaction("responses", "readwrite", { durability: "relaxed" });
const store = tx.objectStore("responses");
for (const item of items) store.put(item);
if (tx.commit) tx.commit();
await transactionDone(tx);

Cross-OS numbers for one transaction writing 100 records × 100 KB (preset B3, median ms):

Engine / OS macOS Win11 Arch
Chrome 33 56 75
Firefox 48 102 127
Safari 92 n/a n/a

A single transaction holding 10 MB of writes completes in 33–127ms across every engine and OS. That is the budget you have to work with. The per-record transaction runs in the prototype show what happens when you repeat setup and commit work for every record: the cross-browser ratios narrow when you batch; they explode when you do not.

Durability is a performance knob

Permalink to "Durability is a performance knob"

IDBTransaction accepts a durability hint:

  • strict: committed only after persistent-storage verification.
  • relaxed: committed when changes reach the OS, no extra fsync.
  • default: browser’s choice.

Numbers for the same workload (100 × 10KB writes, one transaction) across the three durability hints, median ms:

System default relaxed strict
macOS · Chrome 8.0 9.1 11
macOS · Firefox 7.0 6.0 6.0
macOS · Safari 10 9.0 13
Win11 · Chrome 13 13 16
Win11 · Firefox 9.0 11 12
Arch · Chrome 14 15 24
Arch · Firefox 10 11 24

At 1 MB of writes the hint barely moves anything on macOS and Windows. The clearest signal is on Arch Linux, where strict costs ~2× the others in both Chrome and Firefox, likely from a different fsync path or filesystem barrier. Firefox is the most consistent across all three settings; Chrome shows a small but reliable cost for strict. The hint is real and worth setting, but it is not a universal 4× win at this data size. It pays off when you bulk-write tens of megabytes, especially on Linux.

transaction.commit() is a smaller knob. Transactions auto-commit when idle, but explicit commit() lets the browser begin committing immediately after the last queued request. It will not save a bad transaction strategy, but it can reduce tail latency on a busy page.

Structured clone is not free

Permalink to "Structured clone is not free"

IndexedDB stores values via structured clone. That is convenient (objects, Blobs, ArrayBuffers, Maps, Dates), but it has cost on both write and read paths. The shape of the payload matters more than the byte count in some browsers:

  • Small metadata objects clone cheaply.
  • Deeply nested objects clone expensively even when their byte count is modest.
  • Large strings are memory-heavy and may need encoding work.
  • ArrayBuffer is a natural binary shape with predictable cost.
  • Blob is sometimes stored and streamed differently from the wrapped bytes.

A benchmark that stores JSON strings does not tell you how Blob storage behaves. A benchmark on 1KB values does not tell you about 5MB values. Test the shape you plan to ship.

Write shape (200 × 10 KB) Chrome (macOS) Firefox (macOS) Safari (macOS) Firefox (Win11) Firefox (Arch)
ArrayBuffer 18 12 22 19 23
Blob 64 64 193 541 944
String 22 27 34 35 48
Read shape (200 × 10 KB) Chrome (macOS) Firefox (macOS) Safari (macOS) Chrome (Win11) Chrome (Arch)
ArrayBuffer 4.4 8.0 9.0 7.7 13
Blob 14 27 41 65 68
String 4.2 7.0 23 6.4 12

The Firefox Blob write column is not a typo. On Windows it’s 28× slower than ArrayBuffer; on Linux it’s 40× slower. macOS Firefox keeps it to ~5× because the underlying file system handles temp files faster. Chrome is consistently 3–5× across operating systems. Safari Blob writes climb to 80–200ms even at small workloads. The lesson is the same everywhere: if you control the byte layout, store ArrayBuffer and wrap a Blob on demand for the consuming API. String is fine for reads, surprisingly costly for writes on Safari, and competitive on Chromium.

IndexedDB exposes several read APIs with very different cost curves. For a cache, key lookup is the baseline; the rest matter for scans and cleanup.

Primary-key reads are the baseline. A keyPath lookup like store.get(cacheKey) avoids any scan. For a “did we cache this URL?” check, this is the right shape.

Index reads let you query by metadata: expiry, tag, route, content type, LRU candidate. Indexes are not free: every write also updates them. Index the cleanup and query paths you actually need; do not add indexes because the schema “feels” database-y.

Cursor scans historically were the only way to walk a range, and they pay a request round-trip per record. That overhead is real even though browser implementations are fast; the issue is JS↔engine coordination, not disk speed. The gap is small in Chromium and Firefox; in Playwright’s headless WebKit at 500 records I measured the cursor at ~10× getAll (46ms vs 4ms), but in shipping Safari at a smaller scan (100 records) the gap shrinks to roughly 2× (4ms vs 2ms). The direction is consistent; the magnitude scales with both engine and record count. Nolan Lawson’s 2021 post and his 2024 update document the underlying cause in more detail.

getAll() removes the per-record round-trip by returning many records in one request. The trade-off is memory: getAll() materializes every matching value at once. Fine for 1,000 small objects; a memory-pressure experiment with 1,000 5MB blobs.

Paginated getAll() + getAllKeys() is the compromise: bounded batches, cheap per-batch, no giant materialization. The downside is awkwardness; you need both keys and values, plus range bookkeeping.

getAllRecords() is the new piece worth knowing about. Patrick Brosset’s November 2024 write-up explains the motivation: a single request returns key, primary key, and value together, supports batch size, and supports direction. It collapses the cursor-style pagination into one call without the materialization cliff of unbounded getAll().

At time of writing, getAllRecords() is supported in current Chromium-family browsers and not yet in Firefox or Safari (MDN compatibility). Feature-detect with typeof store.getAllRecords === "function" and fall back to paginated getAll.

Scan strategy (500 × 1 KB) Chrome (macOS) Firefox (macOS) Safari (macOS) Chrome (Win11) Chrome (Arch) Firefox (Arch)
openCursor() 4.0 3.0 15 6.7 22 19
Paginated getAll 3.7 2.0 3.0 5.9 7.8 12
getAllRecords() 2.4 n/a n/a 4.6 5.5 n/a

The Safari cursor cliff is real outside Playwright: 15ms cursor vs 3ms paginated getAll at 500 records, a 5× gap on shipping Safari 26.5. The same gap is visible in Chrome on Linux (22 vs 7.8ms, 2.8×) but barely exists in Chrome on macOS (4.0 vs 3.7). getAllRecords() shaves another 30–40% off getAll in Chrome where it is available; outside Chromium, paginated getAll is the portable answer.

IndexedDB is one of five sensible options. Use it when the others do not fit, not by default.

You have Best fit Why
URL-shaped browser resources HTTP cache Native Cache-Control, ETag, memory/disk tiers, no app code
Request/Response pairs you control Cache API Service-worker friendly, response-shaped, async
Records with indexes, metadata, custom invalidation IndexedDB Transactions, indexes, queries
Large binary “files” OPFS File handles, sync access in workers
Small string config localStorage Sync, trivial, tiny quota

A few specific cases that come up:

  • Caching images loaded by <img> is almost always wrong to put in IndexedDB. You read a Blob, mint an object URL, manage lifecycle, and still lack HTTP semantics. Good Cache-Control headers solve the same problem with no application code.
  • Hybrid Cache API + IndexedDB (Cache API for response bodies, IndexedDB for queryable metadata) is a real and useful pattern. Design for partial failure: one write can succeed while the other fails. Transactions do not span both APIs.
  • localStorage for cache payloads: the synchronous main-thread cost is the problem, not the speed. It is fine for tiny config and theme=dark. For anything bigger, the blocked main thread is a worse problem than the API is convenient.

IndexedDB is asynchronous, but asynchronous is not free. Promises, structured clone work, and downstream processing still run on the main thread by default. Moving orchestration into a Worker can recover responsiveness during heavy cache work.

A worker is not magic:

  • Worker startup costs ~5–30ms.
  • Data passed back is cloned again unless transferred.
  • If you immediately hand a large read result to the main thread, you have moved cost, not removed it.

For UI-heavy apps, total wall-clock time is the wrong metric. Measure responsiveness (long tasks, input latency, or animation jank) while the cache is busy. The prototype includes a worker scenario; the more honest production test would also instrument main-thread responsiveness during the run.

A working cache record is more than put() and get():

{
  cacheKey: "GET https://api.example.com/products?page=1",
  status: 200,
  body: blobOrArrayBuffer,
  createdAt: 1770000000000,
  expiresAt: 1770000300000,
  etag: "...",
  tags: ["products", "homepage"],
  version: 3
}

Of those fields, I would only index expiresAt (for cleanup) and tags (for invalidation). Indexing etag, createdAt, version, or contentType adds write cost for query paths most caches never use. Add indexes when a real cleanup or invalidation flow needs them.

Schema versioning is the part most teams underestimate. Bumping DB_VERSION triggers onupgradeneeded, which can be blocked by other tabs holding the old connection. A failed migration leaves users with broken local state. For cache data the safe migration is usually “delete the store and let it rebuild from network.” For user-created offline data, that is not acceptable, and you need a real migration path tested from every version you have shipped.

The logout case is easy to miss. If IndexedDB contains user-specific cache data, clearing auth state does not clear the cache. You need to either delete user-scoped records on logout or partition stores by user/account.

Do not store auth tokens in IndexedDB just because it is async and larger than localStorage. Storage mechanism and token security are different conversations.

Cross-browser numbers, in context

Permalink to "Cross-browser numbers, in context"

The numbers below come from the prototype running in shipping browsers across three operating systems:

  • macOS · Chrome / Firefox / Safari 26.5: 8-core MacBook
  • Win11 · Chrome / Firefox: same network, separate machine
  • Arch Linux · Chrome / Firefox: separate machine, run twice (clean + with background tabs) to gauge noise floor
  • Each row is the median of 10 iterations from the latest export per preset.
  • All runs done in normal (non-private) windows with devtools closed and one tab open.

Before reading any number in this section, the limits of this run:

  • Three machines, not three identical machines. The macOS host is an 8-core Apple Silicon MacBook; the Win11 host is a separate desktop; the Arch host is a third machine. Comparing engines on the same OS is fair (Chrome vs Firefox on macOS); comparing the same engine across OSes is partly machine noise.
  • The prototype, not a real app. The benchmark issues isolated reads/writes against a freshly-cleared database with no other JS on the page. Real apps interleave React renders, network calls, and other I/O. Take the absolute numbers as best-case; expect 1.5–3× more under production load.
  • Median of 10 iterations. Enough to spot 2× differences and order-of-magnitude gaps. Not enough to argue about ±10%. I treat anything inside 10% as a tie.
  • One export per preset per system. Where a number looked off I reran. I have not done a full statistical analysis across reruns; the noise floor is whatever you see between the Arch Firefox “clean” and “with background tabs” columns in the source data, about 5–8% on most rows.
  • No private/incognito. Every browser changes storage semantics in private mode (Safari disables persistent IDB outright, Chrome and Firefox move it in-memory). Private numbers would be faster and meaningless.
  • No headless or automation. Playwright was tried earlier and dropped: automated profiles are systematically faster than user profiles, and mixing one automated browser with two manual ones would produce indefensible deltas. Everything here is hand-clicked in a normal window.
  • Safari column is macOS Safari 26.5. Same WebKit engine as iOS, but without ITP, the 7-day cap, or the storage policy differences a mobile WebView gets. Engine costs port; the eviction story does not.
  • Browsers and engine versions move. This run was captured in May 2026 against the then-current shipping channel of each browser. getAllRecords availability in particular will broaden; rerun the matrix when you ship.
  • Reproduce before you quote. The prototype, presets, and raw JSON exports are in the companion repo. Clone it, click through the presets in your own browser on your own hardware, and treat your numbers as authoritative for your context.

Tier A · Same workload, three APIs

Permalink to "Tier A · Same workload, three APIs"

The same payload exercised against IndexedDB, localStorage, and the Cache API.

A1 · 100 reads × 1 KB (median ms)

System IndexedDB localStorage Cache API
macOS · Chrome 1.9 0.00 13
macOS · Firefox 3.0 0.00 16
macOS · Safari 4.0 0.00 8.0
Win11 · Chrome 3.0 0.00 22
Win11 · Firefox 6.0 0.00 40
Arch · Chrome 5.6 0.10 41
Arch · Firefox 5.0 0.00 43

A2 · 1,000 reads × 1 KB (median ms)

System IndexedDB localStorage Cache API
macOS · Chrome 15 0.40 135
macOS · Firefox 29 0.00 764
macOS · Safari 35 0.00 74
Win11 · Chrome 27 0.60 230
Win11 · Firefox 43 0.00 1,145
Arch · Chrome 55 0.70 402
Arch · Firefox 49 0.00 1,620

A3 · 200 reads × 10 KB (median ms)

System IndexedDB localStorage Cache API
macOS · Chrome 4.4 0.20 26
macOS · Firefox 7.0 0.00 181
macOS · Safari 8.0 0.00 17
Win11 · Chrome 7.8 0.30 45
Win11 · Firefox 15 0.00 98
Arch · Chrome 14 0.30 85
Arch · Firefox 15 0.00 134

What the three tables together actually say:

  • Cache API is the wrong shape for many small entries. At 1,000 × 1KB it ranges from 7× slower than IndexedDB on macOS Chrome to 33× slower on Arch Firefox. The Cache API serializes each key into an HTTP Request/Response, which is the right cost for response bodies and the wrong cost when you are using it as a high-cardinality key/value store. If your “cache” is 1,000 small JSON blobs, IndexedDB is faster and gives you indexes for free.
  • localStorage reads are sub-millisecond regardless of size. At 200 × 10 KB the median is still 0.20–0.30 ms in every browser. The disqualifier for localStorage as a cache is not read latency; it is the synchronous write path on large values blocking the main thread, and the 5 MB origin cap (see Tier C).
  • IndexedDB is the consistent middle option. 1.9–55 ms across every system at the small scales, scaling roughly linearly with entry count. Predictable enough to plan around.
  • OS effect is real. The same Chrome build is 1.9 ms (macOS) → 3.0 ms (Win11) → 5.6 ms (Arch) on the 100-read scenario. At 1,000 reads the gap widens to 15 → 27 → 55 ms, 3.7× slower on Linux than macOS for identical work. Disk encryption, fsync defaults, and filesystem choice all play a part. If your user base skews Windows or Linux, do not benchmark on a MacBook and call it done.

I ran three deliberately stressful presets to see what does not work.

C1 · localStorage overflow (1,000 × 5 KB ≈ 5 MB). Every browser accepted the write within 0.8–3.0 ms. The 5 MB origin cap is still there. Bump the test to 2 MB values × a handful and Safari and Firefox throw QuotaExceededError before Chrome does, but for read patterns at the cap, localStorage itself does not slow down. Its overhead is per-call, not per-byte.

C2 · IndexedDB at larger writes (100 × 100 KB = 10 MB). All eight systems completed all three shape variants. The Firefox/Linux Blob write hit 753 ms (vs 55 ms ArrayBuffer); Safari Blob write hit 106 ms. Same conclusion as B2, amplified. No transaction caps tripped in this run.

C3 · Quota probe (write 1 MB blobs until rejection). The browsers disagreed loudly about how much they would accept before stopping:

System Accepted before stop
macOS · Chrome 1.5 MB
macOS · Firefox 11 MB
macOS · Safari 487 MB
Win11 · Chrome 2.0 MB
Win11 · Firefox 5.8 MB
Arch · Chrome 4.9 MB
Arch · Firefox 5.8 MB

The Chromium numbers are surprisingly low, and I do not fully trust this probe as a quota ceiling: navigator.storage.estimate() reported tens of GB available, so this could be a benchmark artifact, a write-shape limit, or an implementation-specific early stop rather than the real origin budget. Safari at 487 MB shows what happens when WebKit’s new policy lets the app use much more space. Either way, the practical lesson is the same as the docs: a successful estimate() does not promise the next write succeeds. Wrap every write that could go past a few MB in a QuotaExceededError handler with a fallback, and do not design a cache that assumes a fixed budget.

After running the full matrix on three OSes the things I had to revise from my own priors:

  • Cache API is not a faster IndexedDB. I expected it to be roughly even. It is dramatically worse when used outside its Request/Response sweet spot. The post originally hedged on this; the data does not.
  • Firefox is the engine most punished by Blob writes. On Linux the gap to ArrayBuffer is 40×. I had attributed Blob cost to WebKit; the data says Firefox is the worse offender on the platforms most users actually have.
  • The Linux disk path matters more than the engine. Arch Chrome and Arch Firefox land within ~10% of each other on most reads, both notably slower than their macOS counterparts. That points to the storage stack, likely fsync semantics, being the dominant cost there, not browser code.
  • Background tabs barely moved Firefox on Linux. Running the same matrix with several open tabs added a few percent at most. The noise floor for these numbers is lower than I feared, which is good news for anyone reproducing them.
  • It is not a universal benchmark. Different disks, hot vs cold profiles, devtools open, or thermal throttling will move every number. Reproduce the workload on your own hardware before quoting a millisecond.
  • It is not a test of iOS Safari. The Safari column above is macOS Safari 26.5, which shares the WebKit engine but not the ITP policy or 7-day cap that real iOS users hit. If your product lives on iOS, the engine costs here apply; the eviction story does not.
  • It is not enough iterations to draw fine distinctions. Ten iterations per scenario is enough to spot order-of-magnitude differences and 2–3× ratios; it is not enough to argue about 10% gaps. Treat 10% deltas as noise.
  • The full export bundle (including hardware specs, viewport, storage estimate, and high-entropy UA-CH per run) is in the companion repo if you want to verify any of the math or rerun your own.

If you use IndexedDB as a cache, start here:

  • Reach for HTTP cache for normal static resources unless you have a specific reason not to.
  • Reach for Cache API for request/response-shaped caches.
  • Reach for IndexedDB when you need metadata, indexes, custom invalidation, or offline queryable data.
  • Reach for OPFS when the workload is closer to file storage than record storage.
  • Batch writes into as few transactions as reasonable.
  • Use relaxed durability for rebuildable cache data.
  • Call transaction.commit() after the last queued request.
  • Avoid unbounded getAll(); paginate or use getAllRecords() where supported.
  • Test Blob, ArrayBuffer, and string separately; do not assume they behave the same. Prefer ArrayBuffer for storage and wrap a Blob on demand.
  • Keep indexes intentional. Every index adds write cost.
  • Handle QuotaExceededError.
  • Clear user-specific cache on logout or partition by user.
  • Test Safari on a real device if offline persistence is in your value proposition.

The interesting question is not “is IndexedDB fast?” It is “is IndexedDB the right tool for this cache?” When the cache needs application intelligence (metadata, indexes, invalidation, offline queryability), IndexedDB earns its complexity. When the cache is really just URLs and bodies, HTTP cache and Cache API solve the same problem with less code and better browser semantics.

Once you have made that call, the performance answer comes from the same place every time: transaction shape, payload shape, durability, read strategy. The browser differences are real but smaller than the choice of strategy. Pick the right one and the engine usually gets out of your way.

Thank you Andrija, for the Arch Linux benchmarks!