IndexedDB with Claude Code: Beginner-Friendly Local Data Guide
Build IndexedDB storage with Claude Code: schema, sync queues, quota handling, tests, and safe prompts.
IndexedDB is the browser database you reach for when localStorage stops being enough. It stores structured objects, supports indexes, works asynchronously, and can keep an app useful when the network is unreliable. That makes it a practical fit for draft editors, offline queues, searchable caches, media metadata, and local-first product workflows.
The difficult part is not writing a quick demo. The difficult part is designing the schema, upgrading it safely, keeping transactions short, handling quota failures, and deciding what happens when offline edits conflict with server data. If you ask Claude Code to “add IndexedDB” without those boundaries, it can generate code that passes a happy-path click test but fails after the first schema change or offline sync error.
This guide uses a copy-pasteable TypeScript example with the lightweight idb wrapper. It still explains the raw IndexedDB concepts: object stores, indexes, transactions, version upgrades, QuotaExceededError, eviction, testing, and safe Claude Code prompts. Primary references used for this refresh are MDN Using IndexedDB, MDN storage quotas and eviction criteria, web.dev Storage for the web, the idb README, and Dexie.js documentation. For adjacent workflow habits, pair this with Claude Code productivity tips and Claude Code testing strategies.
When IndexedDB Beats localStorage
Use localStorage for small strings: a theme name, a dismissed banner id, the last selected tab, or a short preference object. It is synchronous, simple, and available almost everywhere. Those strengths become weaknesses when you store large JSON arrays, rewrite the whole value on every keystroke, or need to query records by status or timestamp.
IndexedDB is closer to a small browser-side database. An object store is the container for records. A key identifies each record. An index lets you find records by another property. A transaction groups reads and writes so the database does not end up half-updated. MDN describes IndexedDB as a way to persist data in the user’s browser while still supporting richer queries and online/offline behavior.
| Data or workflow | localStorage | IndexedDB |
|---|---|---|
| Theme, UI mode, small preference | Good fit | Usually too much |
| Long draft, form recovery, large JSON | Risky and blocking | Good fit |
| Searchable article or product cache | Becomes one giant JSON blob | Use indexes |
| Blob metadata, attachments, media state | Poor fit | Practical |
| Offline action queue | Hard to order and retry | Good with transactions |
Three concrete app cases appear often. A CMS or blog editor saves drafts with updatedAt and syncStatus, so a user can reload without losing work. A SaaS dashboard caches product, plan, or help-center records and queries them by category or freshness. A PWA keeps an offline queue for comments, contact forms, checkout steps, or admin changes, then flushes the queue when the browser is online. A fourth case is AI-assisted tooling: keep large Claude Code analysis results locally with an expiry time instead of asking the API for the same expensive result on every navigation.
Design the Schema Before the Code
The first IndexedDB question is not “what do we store?” It is “how will we read it later?” If you only fetch by id, the schema is simple. Real applications usually need “dirty drafts only”, “old cache entries first”, “jobs ordered by creation time”, or “failed jobs under three attempts”. Those access patterns need indexes. Adding an index later means increasing the database version and running a migration inside the upgrade callback.
A common failure is starting with a notes store, then later loading every note and filtering in JavaScript because there is no syncStatus index. It feels fine with 50 records and turns into a slow startup with thousands. Another failure is using a decimal version such as 1.1; IndexedDB versions are integer versions, so use 1, 2, 3, and write every migration as a repeatable step.
npm i idb
Create a client-side module such as src/lib/local-db.ts. This sample uses idb because it keeps the IndexedDB model but replaces event-heavy request handling with promises. Dexie is worth considering when you need richer query helpers, live queries, or a higher-level data layer; this guide stays with idb so the beginner path remains small.
import {
openDB,
type DBSchema,
type IDBPDatabase,
} from "idb";
type SyncStatus = "clean" | "dirty" | "syncing" | "failed";
export interface DraftNote {
id: string;
title: string;
body: string;
updatedAt: number;
baseVersion: number;
syncStatus: SyncStatus;
}
export interface SyncJob {
id: string;
noteId: string;
operation: "upsert-note" | "delete-note";
payload: unknown;
createdAt: number;
attempts: number;
lastError?: string;
}
interface AppDB extends DBSchema {
notes: {
key: string;
value: DraftNote;
indexes: {
"by-updated-at": number;
"by-sync-status": SyncStatus;
};
};
syncQueue: {
key: string;
value: SyncJob;
indexes: {
"by-created-at": number;
"by-attempts": number;
};
};
}
const DB_NAME = "claude-code-indexeddb-demo";
const DB_VERSION = 2;
let dbPromise: Promise<IDBPDatabase<AppDB>> | undefined;
export function getDb() {
dbPromise ??= openDB<AppDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, _newVersion, tx) {
if (oldVersion < 1) {
const notes = db.createObjectStore("notes", {
keyPath: "id",
});
notes.createIndex("by-updated-at", "updatedAt");
const queue = db.createObjectStore("syncQueue", {
keyPath: "id",
});
queue.createIndex("by-created-at", "createdAt");
queue.createIndex("by-attempts", "attempts");
}
if (oldVersion < 2) {
const notes = tx.objectStore("notes");
if (!notes.indexNames.contains("by-sync-status")) {
notes.createIndex("by-sync-status", "syncStatus");
}
}
},
blocked() {
console.warn("Close other tabs to finish the DB upgrade.");
},
blocking() {
dbPromise?.then((db) => db.close()).catch(() => {});
},
});
return dbPromise;
}
The important behavior is inside upgrade. New stores and indexes belong there, not in normal read/write functions. If another tab still has the old database open, the new version can be blocked. The blocked handler is where you show a practical message, and blocking closes the current connection so future upgrades are not held hostage by an old tab.
Keep Related Writes in One Transaction
Transactions protect you from half-saved data. Saving a note and adding a sync job are one business action. If the note is written but the queue insert fails, the UI may say “saved” while the server never receives the change. Put both writes in a single readwrite transaction.
const createId = () =>
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2);
export async function saveDraft(input: {
id?: string;
title: string;
body: string;
baseVersion?: number;
}) {
const db = await getDb();
const now = Date.now();
const noteId = input.id ?? createId();
const note: DraftNote = {
id: noteId,
title: input.title,
body: input.body,
updatedAt: now,
baseVersion: input.baseVersion ?? 0,
syncStatus: "dirty",
};
const job: SyncJob = {
id: createId(),
noteId,
operation: "upsert-note",
payload: note,
createdAt: now,
attempts: 0,
};
const tx = db.transaction(["notes", "syncQueue"], "readwrite");
await tx.objectStore("notes").put(note);
await tx.objectStore("syncQueue").put(job);
await tx.done;
return note;
}
export async function getDirtyNotes() {
const db = await getDb();
return db.getAllFromIndex("notes", "by-sync-status", "dirty");
}
The pitfall is awaiting unrelated work inside an active transaction. The idb docs call out transaction lifetime: once the transaction has no pending database work, the browser can close it. Do not call fetch between transaction() and tx.done. Read or write IndexedDB inside the transaction, do network work outside it, then open a new transaction for the result.
Handle Quota, Eviction, and User-Facing Errors
IndexedDB is persistent storage, not guaranteed storage. Browsers can reject writes when the origin is over quota, and best-effort data can be evicted under storage pressure. Private browsing modes may also behave differently. The practical lesson is simple: catch write failures, keep server sync as the durable source for important data, and make cache cleanup part of the design.
Two production-style failures are common. First, the app catches nothing, so QuotaExceededError appears only in the console while the UI keeps saying “saved”. Second, the app treats IndexedDB as the only copy of paid, legal, or customer-critical data. IndexedDB is excellent for local work and offline continuity, but important records should eventually sync to a server that can resolve ownership and retention rules.
export async function estimateStorage() {
if (!navigator.storage?.estimate) {
return { usage: undefined, quota: undefined };
}
const { usage, quota } = await navigator.storage.estimate();
return { usage, quota };
}
export async function requestPersistentStorage() {
if (!navigator.storage?.persist) {
return false;
}
return navigator.storage.persist();
}
export function isQuotaError(error: unknown) {
return (
error instanceof DOMException &&
error.name === "QuotaExceededError"
);
}
export async function saveDraftWithErrorMessage(
input: Parameters<typeof saveDraft>[0],
) {
try {
return await saveDraft(input);
} catch (error) {
if (isQuotaError(error)) {
throw new Error(
"Browser storage is full. Delete old drafts and try again.",
);
}
throw error;
}
}
navigator.storage.persist() is a request, not a magic lock. Some browsers grant it only when the site has meaningful engagement or the user explicitly approves. Pair it with estimate(), cache pruning, sync, and clear error states. When Claude Code edits this area, ask it to update the UI copy and tests as part of the same task.
Build an Offline Queue Without Hiding Conflicts
An offline queue needs more than “store jobs and send later.” You need ordering, retry limits, duplicate handling, and a conflict rule. Suppose a user edits a note offline on one laptop and edits the same note online from another device. When the offline browser comes back, blindly replaying the stale local job can overwrite newer server data.
type SendJob = (job: SyncJob) => Promise<void>;
export async function flushSyncQueue(sendJob: SendJob) {
if (!navigator.onLine) return;
const db = await getDb();
const jobs = await db.getAllFromIndex(
"syncQueue",
"by-created-at",
);
for (const job of jobs) {
try {
await sendJob(job);
await db.delete("syncQueue", job.id);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown sync error";
await db.put("syncQueue", {
...job,
attempts: job.attempts + 1,
lastError: message,
});
}
}
}
window.addEventListener("online", () => {
void flushSyncQueue(async (job) => {
const response = await fetch("/api/sync", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(job),
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`);
}
});
});
Conflict handling depends on the domain. A personal note can show the local and remote versions and let the user choose. Inventory, invoices, reservations, and billing events should usually be guarded on the server with a version number, ETag, or idempotency key. The browser can detect a likely conflict, but the server should decide whether a write is still valid.
export function detectConflict(input: {
local: DraftNote;
remoteVersion: number;
}) {
const { local, remoteVersion } = input;
return (
local.syncStatus === "dirty" &&
remoteVersion > local.baseVersion
);
}
Test What Mocks Can Test
fake-indexeddb is useful for unit tests because it gives Node an IndexedDB-like environment. Use it to test migrations, save functions, indexes, and queue inserts. Do not use it as proof for quota behavior, browser eviction, private mode, multi-tab blocking, or mobile storage pressure. Those need a real browser check.
npm i -D vitest fake-indexeddb
import "fake-indexeddb/auto";
import { beforeEach, expect, test } from "vitest";
import { deleteDB } from "idb";
import {
getDirtyNotes,
saveDraft,
} from "./local-db";
beforeEach(async () => {
await deleteDB("claude-code-indexeddb-demo");
});
test("saves a draft and finds it by sync status", async () => {
await saveDraft({
title: "IndexedDB note",
body: "Saved while offline",
});
const dirtyNotes = await getDirtyNotes();
expect(dirtyNotes).toHaveLength(1);
expect(dirtyNotes[0]?.syncStatus).toBe("dirty");
});
A practical verification checklist is short: first launch creates the database, version 2 adds the syncStatus index, another open tab produces an upgrade warning, offline save survives reload, online recovery flushes the queue, and storage pressure produces a readable error. For monetized pages, also confirm article CTAs, product links, consultation links, and analytics events still fire after the storage layer is added.
Safe Claude Code Prompt
The prompt should define the local data contract, not just request a feature. IndexedDB has too many edge cases for a vague “make it offline” task.
claude <<'PROMPT'
Scope:
- Edit only src/lib/local-db.ts and src/lib/local-db.test.ts.
- Do not change API routes, pricing copy, analytics, or CTA links.
Build:
- Use IndexedDB through the idb package.
- Database name: claude-code-indexeddb-demo.
- Version 2 must create notes and syncQueue stores.
- Add indexes for updatedAt, syncStatus, createdAt, and attempts.
Reliability:
- Use readwrite transactions for note + queue writes.
- Do not await fetch inside an active IndexedDB transaction.
- Handle QuotaExceededError with a user-facing message.
- Explain blocked upgrades from another open tab.
Testing:
- Use vitest and fake-indexeddb.
- Test draft save, dirty-note lookup, and queue insertion.
- Return findings, patch summary, commands, and residual risk.
PROMPT
This turns Claude Code into a reviewer of a local data layer, not just a generator of browser storage code. If your site is monetized, the same prompt discipline protects revenue paths: offline drafts, caches, or queues should not remove CTA links, break analytics, or hide product copy. Teams can use Claude Code training and consultation to turn this into a project checklist; solo builders can start with the free cheatsheet and Claude Code templates.
Hands-On Verification Note
Masa tested this pattern in a small note editor. The biggest improvement came from deciding the syncStatus and updatedAt indexes before the first implementation. The first version filtered all notes in JavaScript, which looked fine with sample data and became noticeably heavier as the test set grew. Moving to idb, saving the note and queue item in one transaction, and adding a visible quota error made the behavior easier to review. fake-indexeddb covered the save and lookup tests, but it did not prove quota or tab-blocking behavior. The final check used Chrome DevTools to clear storage, switch the network offline, save a note, reload, go online, and confirm that the queued request was sent.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.