IndexedDB dengan Claude Code: panduan data lokal yang aman
Implementasi IndexedDB dengan Claude Code: schema, migrasi, index, transaction, quota, offline sync, dan test.
IndexedDB adalah database di browser untuk saat localStorage sudah tidak cukup. Ia bisa menyimpan object terstruktur, memakai index, berjalan async, dan mengelompokkan beberapa write dalam transaction. Untuk draft offline, cache yang bisa dicari, queue sinkronisasi, metadata media, atau hasil analisis AI yang besar, IndexedDB jauh lebih cocok daripada satu JSON besar di localStorage.
Bagian sulitnya bukan membuat demo. Bagian sulitnya adalah schema yang bisa dimigrasikan, transaction yang tidak tertutup di tengah jalan, handling QuotaExceededError, perilaku saat beberapa tab terbuka, dan aturan ketika edit offline bentrok dengan data server. Kalau prompt ke Claude Code hanya “tambahkan IndexedDB”, hasilnya bisa jalan di happy path tetapi gagal saat upgrade atau sync pertama.
Artikel ini memakai TypeScript dan wrapper ringan idb. Referensi utama yang dipakai: MDN Using IndexedDB, MDN storage quotas and eviction criteria, web.dev Storage for the web, idb README, dan Dexie.js docs. Untuk kebiasaan prompt yang lebih aman, baca juga tips produktivitas Claude Code dan strategi testing.
Kapan IndexedDB lebih tepat dari localStorage
localStorage cocok untuk string kecil: tema, bahasa, tab terakhir, atau preferensi singkat. API-nya mudah, tetapi synchronous, bisa memblokir main thread, tidak punya index, dan buruk untuk list besar yang ditulis ulang sebagai JSON setiap ada perubahan.
IndexedDB lebih mirip database lokal kecil. Object store menyimpan record. Key menjadi identitas record. Index memungkinkan query lewat field lain. Transaction menggabungkan beberapa read/write supaya state tidak setengah jadi. Ia tidak menggantikan database server, tetapi membuat pengalaman browser lebih tahan terhadap koneksi buruk dan reload mendadak.
| Kebutuhan | localStorage | IndexedDB |
|---|---|---|
| Tema, mode UI, preferensi kecil | Cocok | Biasanya berlebihan |
| Draft panjang atau recovery form | Rapuh dan blocking | Cocok |
| Cache artikel atau produk yang bisa dicari | Jadi JSON besar | Pakai index |
| Blob, attachment, metadata media | Kurang cocok | Praktis |
| Queue aksi offline | Urutan dan retry sulit | Cocok dengan transaction |
Use case nyata ada banyak. CMS menyimpan draft dengan updatedAt dan syncStatus. Dashboard SaaS menyimpan produk, paket harga, atau artikel bantuan lalu query berdasarkan category atau freshness. PWA menyimpan komentar, form konsultasi, atau perubahan admin ke offline queue lalu mengirim saat online. Tool berbasis Claude Code bisa menyimpan hasil analisis besar dengan expiry supaya tidak mengulang request mahal.
Rancang schema, version, dan index dulu
Pertanyaan pertama bukan “data apa yang disimpan?”, tetapi “nanti dibaca dengan kondisi apa?”. Kalau hanya mengambil berdasarkan id, mudah. Di app nyata, kamu butuh “draft dirty saja”, “cache paling lama”, “job berdasarkan createdAt”, atau “job dengan attempts rendah”. Semua itu butuh index. Menambahkan index belakangan berarti menaikkan version DB dan menulis migration.
Kegagalan umum: awalnya hanya membuat store notes, lalu karena tidak ada index syncStatus, app membaca semua note dan filter di JavaScript. Dengan 50 record tidak terasa; dengan ribuan record, startup berat. Kegagalan lain adalah version decimal seperti 1.1. IndexedDB sebaiknya memakai integer version: 1, 2, 3.
npm i idb
Module berikut bisa ditempatkan di src/lib/local-db.ts. idb menjaga model IndexedDB tetap terlihat. Jika butuh query helper yang lebih kaya, live query, atau data layer yang lebih tinggi, Dexie juga layak dipilih; artikel ini memakai idb agar jalur awal tetap kecil.
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;
}
Perubahan struktur selalu ada di upgrade. Jangan membuat store atau index dari fungsi baca/tulis biasa. Kalau tab lain masih membuka version lama, upgrade bisa terblokir. blocked memberi pesan ke user, dan blocking menutup connection saat tab ini bisa menghambat migration berikutnya.
Simpan draft dan queue dalam satu transaction
Menyimpan draft dan memasukkan sync job adalah satu aksi bisnis. Jika note tersimpan tetapi queue gagal, UI terlihat aman padahal server tidak pernah menerima perubahan. Gunakan transaction readwrite untuk kedua store.
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");
}
Jangan menunggu fetch di tengah transaction aktif. Saat tidak ada pekerjaan IndexedDB yang pending, browser bisa menutup transaction. Selesaikan write lokal dulu, lakukan network di luar transaction, lalu buka transaction baru untuk menyimpan hasil.
Tangani quota, eviction, dan error yang terlihat
IndexedDB bukan storage tak terbatas. Browser bisa menolak write ketika origin melewati quota, dan data best effort dapat dihapus saat storage pressure. Private mode juga bisa berbeda. Karena itu, tangkap error, siapkan pembersihan cache lama, dan sync data penting ke server.
Dua failure sering terjadi. Pertama, QuotaExceededError hanya muncul di console sementara UI tetap bilang “tersimpan”. Kedua, data penting seperti pembayaran, kontrak, stok, atau support hanya hidup di IndexedDB. Untuk data semacam itu, IndexedDB adalah area kerja offline atau queue, bukan sumber permanen.
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(
"Storage browser penuh. Hapus draft lama lalu coba lagi.",
);
}
throw error;
}
}
persist() adalah request, bukan jaminan. Minta Claude Code menambahkan pesan UI, cleanup cache, dan test, bukan hanya try/catch diam-diam.
Offline queue dan konflik sync
Offline queue perlu urutan, retry, deduplikasi, dan aturan konflik. Jika note diedit offline di satu perangkat dan online di perangkat lain, replay job lama bisa menimpa data server yang lebih baru.
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}`);
}
});
});
Untuk note pribadi, menampilkan versi lokal dan remote bisa cukup. Untuk inventory, booking, invoice, atau payment event, server harus memutuskan dengan version, ETag, atau idempotency key.
export function detectConflict(input: {
local: DraftNote;
remoteVersion: number;
}) {
const { local, remoteVersion } = input;
return (
local.syncStatus === "dirty" &&
remoteVersion > local.baseVersion
);
}
Testing dan batas mock
fake-indexeddb membuat Vitest bisa berjalan di Node dengan API mirip IndexedDB. Pakai untuk save draft, query index, insert queue, dan migration. Jangan anggap itu bukti untuk quota, eviction, private mode, multi-tab blocking, atau storage pressure di mobile. Hal itu perlu browser nyata.
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");
});
Sebelum publish, cek: first load membuat DB, version 2 menambahkan syncStatus, tab lain memunculkan pesan upgrade, draft offline tetap ada setelah reload, queue terkirim saat online, dan error storage jelas. Untuk halaman monetized, CTA, link produk, link konsultasi, dan analytics event harus tetap jalan.
Prompt aman untuk Claude Code
Prompt harus mendefinisikan kontrak data lokal. “Buat offline” terlalu kabur untuk IndexedDB.
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
Dengan prompt ini, Claude Code membuat local data layer yang bisa direview, bukan sekadar snippet storage. Di site monetized, draft offline, cache, dan queue tidak boleh merusak CTA, link pembelian, form konsultasi, atau analytics. Tim bisa menjadikannya checklist lewat training dan konsultasi Claude Code. Developer solo bisa mulai dari cheatsheet gratis dan template Claude Code.
Catatan verifikasi langsung
Masa menguji pattern ini di editor note kecil. Perbaikan terbesar datang dari menentukan index syncStatus dan updatedAt sejak awal. Versi pertama membaca semua note lalu filter di JavaScript; ketika data test bertambah, startup terasa berat. Setelah pindah ke idb dan memakai satu transaction untuk note + queue, state gagal lebih mudah direview. fake-indexeddb menguji save dan lookup, tetapi tidak membuktikan quota atau tab blocking. Cek terakhir dilakukan di Chrome DevTools: clear storage, ubah network ke Offline, simpan, reload, kembali Online, lalu pastikan request queue terkirim.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission safety ladder Claude Code: perluas akses tanpa kehilangan kontrol
Naik dari read-only ke edit terbatas, command bukti, dan cek deploy dengan kontrol yang jelas.
Claude Code Small PR Proof Pack: perubahan kecil yang mudah direview
Paket bukti untuk PR Claude Code: diff, check, URL publik, jalur CTA, dan rollback.
Review gate Claude Code sebelum commit: diff, test, URL publik, dan CTA
Cara memakai Claude Code sebelum commit: diff scope, build, URL publik, link Gumroad, CTA konsultasi, missing test, dan file tidak terkait.