HomeJournalThis post

Cache API vs IndexedDB for Offline Apps

Split offline response snapshots from mutable records, transactions, outbox state, and recovery copy with a deliberate Cache API and IndexedDB hybrid.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Cache API vs IndexedDB is a choice between request-response snapshots and structured mutable records, not two interchangeable places to put offline bytes. This comparison traces one field workflow through both stores, defines a deliberate hybrid, and writes recovery messages that never promise more than the committed state.

Start Cache API vs IndexedDB with the model job

Cache API vs IndexedDB is not a contest between two generic databases. Cache storage holds request and response pairs. IndexedDB holds structured values behind keys, indexes, and transactions. An offline product usually needs both jobs: replay a previously fetched shell or snapshot, and preserve mutable local records such as inspections, drafts, attachments, and an outbox.

Begin with one field workflow. A technician opens today's assignment, edits observations, attaches a note, submits when connected, and later returns after an app update. Mark the authority for every state. A cached GET response is a server snapshot; a local inspection record is the user's unsynced work; an outbox entry is an intent awaiting acknowledgment. Once those meanings are explicit, the storage choice becomes much easier.

The Service Worker specification describes Cache as a request and response store, which is exactly the right mental boundary. Do not put mutable domain records into opaque response bodies merely because matching a URL feels convenient. Do not unpack every static response into records when HTTP-shaped replay is the real job. Model ownership first, then use each primitive where its semantics make the failure story honest. Cache API vs IndexedDB starts with that semantic split, not a storage-size comparison.

Split offline topology by authorityA service-worker cache lane stores shell and server response snapshots while an IndexedDB lane stores mutable inspection records and an outbox, joined by a read-composition bridge.CACHE APIshell assetsGET snapshotsfreshness + versionINDEXEDDBinspection recordoutbox intenttransaction + revisionCOMPOSEread view
The hybrid works because response snapshots and mutable user records keep distinct authorities before they are composed for reading.
Cache authority
Request-response snapshot and its freshness.
IndexedDB authority
Locally committed inspection revision and pending outbox intent.
Composition rule
Start from the snapshot, overlay the higher local revision, and display sync status.
Forbidden promise
A cached response does not prove the user's edit committed or synced.

Use Cache semantics for request snapshots

A cache entry is selected through request matching and returns a Response. That makes it natural for application shells, immutable assets, and bounded API snapshots that can be named by URL plus relevant request properties. A service worker can answer while offline, but the application still needs a freshness policy. “Available” and “current” are different states.

Version cache names with the shell or schema they contain. Populate a new cache before activation, switch readers only when required entries exist, and remove obsolete caches after the new worker owns the page. For data snapshots, store a fetched-at time or version in headers or a companion record, then render a visible stale state when refresh fails. HTTP caching without superstition helps distinguish browser HTTP cache behavior from application-managed Cache storage.

Cache writes do not provide a transaction across an arbitrary set of request-response pairs and local domain edits. A successful put also says nothing about server acknowledgment of the user's latest work. The artifact's cache-like adapter therefore records snapshot revision and freshness only. It never emits “saved” for an inspection edit. That copy belongs to the record transaction and sync state, not to the presence of a response under a URL. A service worker cache can replay context without becoming the record of user intent.

Use IndexedDB transactions for mutable records

IndexedDB provides object stores, indexes, key ranges, and transactions. The Indexed Database specification defines transaction lifecycle, commit, abort, and upgrade behavior. Those semantics fit structured records whose fields change independently and must be queried: inspections by status, outbox entries by retry time, or attachments by parent record.

Keep transaction scope small and synchronous in intent. Read the current record, validate the transition, write the new revision, and enqueue the sync operation within one read-write transaction when they must succeed together. If the transaction aborts, neither the visible saved revision nor the outbox should advance. A UI that reports success before completion creates a false durability promise.

The offline draft queue with IndexedDB shows the focused outbox pattern. The broader offline web app storage design also needs cache snapshots, migrations, and recovery after clearing. Store normalized domain data rather than rendered screens, and version record schemas explicitly. IndexedDB can preserve complex local work, but the API does not automatically resolve server conflicts, choose retention policy, or make best-effort storage permanent. Cache API vs IndexedDB therefore compares ownership and transaction boundaries before convenience.

Keep IndexedDB transactions observable in tests. Record the previous revision, candidate revision, transaction outcome, and outbox effect. That small ledger prevents UI code from inferring success merely because a request was scheduled.

Run the same field workflow through both lanes

Walk six operations across the two stores: install shell, fetch assignment, edit inspection, reopen offline, submit, and refresh after acknowledgment. The cache lane owns shell and GET snapshot versions. The record lane owns the editable inspection revision and outbox. Network state influences refresh and submit, but it does not rewrite which store is authoritative.

At install, a new shell cache is staged. At fetch, the response snapshot advances only after a complete response is available. At edit, one IndexedDB-like transaction increments the inspection revision and adds an outbox entry. Reopen offline reads the shell and snapshot for context, then overlays the newer local record. Submit sends the outbox operation; acknowledgment clears it and records the server revision. Refresh can then replace the snapshot without erasing the local record.

The semantic matrix under the lane diagram repeats every operation, owner, commit boundary, and user message. That accessible equivalent matters because an architecture diagram alone should not be the only source of state meaning. Cache API vs IndexedDB becomes concrete when the same user action can be traced through both lanes and each message names what actually committed. Rehearse the lane sequence with airplane mode, an aborted transaction, and a stale snapshot before writing product copy. The test should fail if both stores ever claim authority over the same revision without an explicit reconciliation step.

Six operations across Cache and IndexedDB lanesSix numbered operations cross parallel blue Cache and amber IndexedDB lanes with commit diamonds only on the store that owns each state.123456CACHE LANERECORD LANE
Commit markers stay on the owning lane: shell and response revisions in Cache; edits and outbox changes in the record transaction.
Six-operation lane comparison
StepOperationOwnerCommit meaning
1Install shellCacheRequired responses staged
2Fetch assignmentCacheSnapshot revision advances
3Edit inspectionIndexedDBRecord and outbox commit together
4Reopen offlineHybridSnapshot plus local overlay
5SubmitIndexedDB/networkOutbox clears after acknowledgment
6RefreshCacheNew snapshot cannot erase local work

Design the hybrid around authority

A deliberate hybrid has a read composition rule. Start from the latest usable server snapshot, overlay locally committed records by identity, and decorate with sync status. Never let a background snapshot refresh overwrite a higher local revision. When the server acknowledges a mutation, reconcile versions and clear only the matching outbox entry.

Separate binary assets by access pattern. A cache is useful when an attachment is fetched and replayed as a response. IndexedDB may be appropriate for bounded locally created blobs tied to a record transaction, but large media can pressure quota quickly. SQLite in OPFS and OPFS versus user-visible files cover alternatives for richer local project stores; neither changes the need to state user custody honestly.

The lab uses pure adapters, not browser APIs, so every transition is deterministic. Cache keys, record revisions, outbox entries, digests, freshness, and recovery messages appear in one normalized receipt. That abstraction is intentionally modest: it proves the state protocol under injected failures, not real browser throughput, quota, eviction order, or durability. Implement the same invariants around the platform APIs and verify them in target browsers. Cache API vs IndexedDB remains a protocol decision even when a framework wraps both APIs.

Version, expire, and migrate separately

Shell versions, response freshness, record schemas, and sync protocols change on different clocks. Give each one a separate version. A service worker update can stage a new shell without touching user records. A stale API snapshot can expire without deleting a draft. An IndexedDB version upgrade can migrate records while leaving the old worker active until every tab releases the database.

Blocked upgrades are a product state, not merely a console message. Tell the user that another tab must close, preserve the old readable path, and do not claim the new schema is active. Test an upgrade abort and confirm the prior database remains usable. Service worker update flows apply the same principle to shell activation: coordinate lifecycle changes instead of assuming reload will make them atomic. IndexedDB transactions make the record boundary explicit, but product code must still wait for completion.

Expiration must also respect authority. Delete replaceable snapshots before unsynced records. Keep migration code idempotent and attach a schema version to every normalized fixture. If an old record cannot migrate, quarantine it with an export or support path rather than silently dropping it. The progressive web app data model is trustworthy when every cleanup rule states what can be reacquired and what represents unique user work. A Cache API vs IndexedDB review must preserve that distinction through every version change.

Treat quota, eviction, and failure as ordinary paths

The Storage Standard defines the platform's storage architecture, quota estimates, and persistence. Best-effort data can be cleared, estimates are not reservations, and a write can still fail. Therefore “works offline” cannot mean “these bytes will exist forever.” Ask for persistence when appropriate, but continue to design recovery when it is denied or storage is cleared.

Inject the failures separately. A stale cached response plus refresh failure should render stale content with a timestamp. A transaction abort must leave record revision and outbox unchanged. A blocked upgrade should keep the old schema readable. Quota refusal must not produce a saved badge. Network loss should retain the committed outbox. Cleared best-effort storage should open a recovery screen that explains what can be downloaded again and whether unsynced work is gone.

Prioritize cleanup by replaceability: obsolete shell caches, old response snapshots, re-downloadable media, then optional derived indexes. Never auto-delete the only copy of a user's work to make a background refresh succeed. The lab's failure timeline shows state before injection, the preserved authority, and the exact message after it. That is more useful than a green “offline ready” badge because it tells the team what the promise costs. Cache API vs IndexedDB is only complete when this deletion order is documented.

Failure timeline keeps recovery copy honestA five-state timeline shows a committed local edit surviving network loss while an aborted edit never receives a saved message and cleared storage ends in explicit recovery.OPENCOMMITOFFLINEABORTRECOVERSaved on this deviceSync pendingEdit not savedName what survived
Every message follows the actual commit boundary; network and storage failures never borrow a stronger word than the receipt supports.
  1. Open: show snapshot time and freshness.
  2. Commit: “Saved on this device; waiting to sync.”
  3. Network loss: retain the outbox and show “sync pending.”
  4. Transaction abort: keep the old revision and say “edit not saved.”
  5. Storage cleared: explain what can be downloaded again and what cannot be restored.

Write recovery copy and tests before the promise

Recovery copy should name the surviving truth. Say “Showing the assignment from 09:40; refresh failed,” “Saved on this device; waiting to sync,” or “This edit was not saved because local storage is full.” Avoid “All changes saved” until the record transaction has committed, and avoid “Synced” until the server has acknowledged the matching operation. If storage was cleared, do not imply that logging in can restore work that never reached the server.

The downloadable lab runs a field-inspection fixture through normal and hostile traces. It accepts at least two custom fixtures, bounds all strings and collections, rejects unsafe keys, normalizes revisions and digests, and reports freshness, outbox, recovery, and message. Independent tests recompute final state, inject stale response, refresh failure, transaction abort, blocked upgrade, quota refusal, network loss, and cleared storage, then kill a mutant that emits a false saved state after abort.

Cache API vs IndexedDB is ultimately a promise-design exercise. Request-response snapshots and structured mutable records solve different jobs, and a hybrid works only when authority stays explicit across refresh, edit, migration, and loss. Publish the claim boundary with the architecture: this model does not benchmark devices, guarantee quota, or prove durability. It does let a team model one real screen, test every failure message, and reserve “works offline” for behavior users can actually recover.

Runnable artifact — Pure cache-like and IndexedDB-like state protocol; not a browser benchmark, quota reservation, eviction prediction, or durability guarantee.

JavaScript8 lines
import {createHash} from "node:crypto";import {readFileSync} from "node:fs";
const MAX_BYTES=262144,VERSION="offline-store-lab-v1",failures=new Set(["none","stale-response","refresh-failure","tx-abort","blocked-upgrade","quota-refusal","network-loss","cleared-storage"]),builtIn={screen:"field-inspection",cache:{key:"/api/assignments/today",revision:3,fetchedAt:"2026-09-17T12:00:00Z",body:{assignment:"A-17",site:"North"}},record:{id:"A-17",revision:1,status:"draft",notes:"Initial"},edits:{notes:"Valve checked",status:"ready"},serverRevision:4};
const normalize=v=>Array.isArray(v)?v.map(normalize):v&&typeof v==="object"?Object.fromEntries(Object.keys(v).sort().map(k=>[k,normalize(v[k])])):v,canonical=v=>JSON.stringify(normalize(v)),hash=v=>createHash("sha256").update(typeof v==="string"?v:canonical(v)).digest("hex"),safeKey=k=>typeof k==="string"&&/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(k),safeCacheKey=k=>typeof k==="string"&&/^\/[A-Za-z0-9][A-Za-z0-9._~:/?&=%+-]{0,255}$/.test(k),unsafe=new Set(["__proto__","prototype","constructor"]);
function parse(){const a=process.argv.slice(2);let fixture=null,failure="none";for(let i=0;i<a.length;i+=2){if(i+1>=a.length||!new Set(["--fixture","--failure"]).has(a[i]))throw new Error("invalid-arguments");if(a[i]==="--fixture"){if(fixture)throw new Error("duplicate-fixture");fixture=a[i+1];}else failure=a[i+1];}if(!failures.has(failure))throw new Error("invalid-failure");if(!fixture)return{input:structuredClone(builtIn),failure};const bytes=readFileSync(fixture);if(bytes.length>MAX_BYTES)throw new Error("fixture-too-large");try{return{input:JSON.parse(bytes.toString("utf8")),failure};}catch{throw new Error("malformed-fixture");}}
function scan(value,depth=0){if(depth>8)throw new Error("fixture-too-deep");if(Array.isArray(value)){if(value.length>256)throw new Error("fixture-too-wide");for(const item of value)scan(item,depth+1);}else if(value&&typeof value==="object"){for(const [key,item] of Object.entries(value)){if(unsafe.has(key)||!safeKey(key))throw new Error("unsafe-key");scan(item,depth+1);}}else if(typeof value==="string"&&Buffer.byteLength(value)>8192)throw new Error("string-too-large");else if(typeof value==="number"&&!Number.isFinite(value))throw new Error("invalid-number");}
function validate(f){scan(f);if(!f||typeof f!=="object"||Array.isArray(f)||Object.keys(f).some(k=>!["screen","cache","record","edits","serverRevision"].includes(k))||!safeKey(f.screen))throw new Error("invalid-fixture");if(!f.cache||!safeCacheKey(f.cache.key)||!Number.isSafeInteger(f.cache.revision)||f.cache.revision<0||typeof f.cache.fetchedAt!=="string"||!f.cache.body)throw new Error("invalid-cache");if(!f.record||!safeKey(f.record.id)||!Number.isSafeInteger(f.record.revision)||f.record.revision<0||!f.edits||typeof f.edits!=="object"||Array.isArray(f.edits)||Object.keys(f.edits).some(k=>!["status","notes"].includes(k))||Object.values(f.edits).some(v=>typeof v!=="string")||!Number.isSafeInteger(f.serverRevision)||f.serverRevision<0)throw new Error("invalid-record");return f;}
function run(f,failure){let cache=structuredClone(f.cache),record=structuredClone(f.record),outbox=[],committedRevision=record.revision,network=failure!=="network-loss",trace=[];const push=(operation,owner,state,message)=>trace.push({step:trace.length+1,operation,owner,state,message});push("open-shell","cache",cache?"available":"missing",cache?"App shell and saved response are available.":"Offline shell is unavailable.");if(failure==="cleared-storage"){cache=null;record=null;push("restore","hybrid","cleared","Local best-effort storage was cleared. Download server data again; unsynced work cannot be restored from this device.");return finish();}if(failure==="stale-response"||failure==="refresh-failure")push("read-assignment","cache","stale","Showing the assignment from "+f.cache.fetchedAt+"; refresh failed.");else push("read-assignment","cache","fresh","Showing the latest downloaded assignment snapshot.");if(failure==="blocked-upgrade")push("upgrade","indexeddb","blocked","Close the other tab to finish the local data update; the existing record remains readable.");if(failure==="tx-abort"||failure==="quota-refusal")push("edit-inspection","indexeddb",failure,"This edit was not saved because the local transaction did not commit.");else{if(record.revision>=Number.MAX_SAFE_INTEGER)throw new Error("record-revision-overflow");record={...record,...structuredClone(f.edits),revision:record.revision+1};committedRevision=record.revision;outbox.push({recordId:record.id,revision:record.revision,digest:hash(record)});push("edit-inspection","indexeddb","committed","Saved on this device; waiting to sync.");}if(outbox.length&&network&&f.serverRevision>=committedRevision){outbox=[];record={...record,serverRevision:f.serverRevision};push("submit","hybrid","acknowledged","Synced after the server acknowledged revision "+f.serverRevision+".");}else if(outbox.length&&network)push("submit","hybrid","rejected-stale-ack","Server acknowledgement revision "+f.serverRevision+" is older than local revision "+committedRevision+"; sync remains pending.");else if(outbox.length)push("submit","hybrid","queued","Saved on this device; network is unavailable and sync is pending.");if(failure==="refresh-failure")push("refresh","cache","preserved","Refresh failed; the previous response snapshot remains available.");else if(network&&failure!=="stale-response"){if(cache.revision>=Number.MAX_SAFE_INTEGER)throw new Error("cache-revision-overflow");cache={...cache,revision:cache.revision+1,fetchedAt:"2026-09-17T12:05:00Z"};push("refresh","cache","committed","Downloaded response snapshot revision "+cache.revision+".");}function finish(){const core={schema:VERSION,failure,cache,record,committedRevision,outbox,freshness:cache?(failure==="stale-response"||failure==="refresh-failure"?"stale":"fresh"):"missing",trace,claimBoundary:"Pure cache-like and IndexedDB-like state protocol; not a browser benchmark, quota reservation, eviction prediction, or durability guarantee."};return{...core,stateDigest:hash({cache,record,outbox}),receiptHash:hash(core)};}return finish();}
const parsed=parse(),input=validate(parsed.input),receipt=run(input,parsed.failure);process.stdout.write(JSON.stringify({...receipt,inputDigest:hash(input),replayCommand:"node offline-store-lab.mjs"+(process.argv.includes("--fixture")?" --fixture <same-path>":"")+(parsed.failure!=="none"?" --failure "+parsed.failure:"")},null,2)+"\n");