Svelte 5 `$state` won't save to IndexedDB: fixing DataCloneError
Why your reactive state fails structured clone, and the fix I shipped.

Search for a command to run...
Why your reactive state fails structured clone, and the fix I shipped.

No comments yet. Be the first to comment.
Every team has one complaint that never becomes a proper task. Someone says it, everyone nods, nothing happens. One of our complaints was "the VPN is slow sometimes, or the VPN feels too slow sometime

I built a recipe keeper called Recipe Jar. You paste a recipe link, it gives you a clean card with just the ingredients and steps, and you can save as many recipes as you want. No account, no ads, wor

My Ubuntu server kept freezing. No warning. No error. Just completely stuck. Screen frozen, keyboard dead, only option was to hold the power button and force restart. This happened randomly. Sometimes

Let me tell you about this week(Obv. mine). I pressed the power button on our office’s Ubuntu server. Fans started spinning. CPU light turned on. Everything sounded normal. But the monitor? Completely black. Nothing. Not even the BIOS screen showed u...

Building Recipe Jar, I store every recipe in the browser's IndexedDB. No server, no account, the data just lives on your device. The first time I wired the save button in Svelte 5, it threw this:
DataCloneError: Failed to execute 'add' on 'IDBObjectStore':
#<Object> could not be cloned.
The object looked completely normal in the console. Plain title, an array of ingredients, some numbers. Nothing weird. But IndexedDB refused to store it.
Here is the code that broke:
<script lang="ts">
let recipe = $state<Recipe>({ title: '', ingredients: [] })
async function save() {
await db.recipes.add(recipe) // DataCloneError
}
</script>
Svelte 5 makes state reactive by wrapping it in a Proxy. When you write $state({ ... }), you don't get back a plain object. You get a proxy that intercepts reads and writes so the UI can update.
IndexedDB stores values using the structured clone algorithm. Structured clone knows how to copy plain objects, arrays, dates, and a fixed set of built-in types. It does not know how to copy a Proxy. So the moment that reactive object reaches the database boundary, the clone fails and you get DataCloneError.
It looks like plain data to you because the proxy is transparent when you read it. The clone algorithm sees the wrapper, not the data.
My recipe data is plain JSON: strings, numbers, arrays, no dates or Maps or Sets. So the fix I shipped is a JSON round-trip that reads through the proxy and hands back a plain object:
// db.ts
function toPlainRecipe(recipe: Recipe): Recipe {
return JSON.parse(JSON.stringify(recipe)) as Recipe
}
JSON.stringify walks the proxy and pulls the values out, and JSON.parse rebuilds a plain object with no proxy attached. Because a recipe is JSON-only data, nothing is lost.
If your state is not pure JSON, reach for Svelte's built-in instead:
await db.recipes.add($state.snapshot(recipe))
$state.snapshot() returns a deep, static copy with the proxies stripped, and unlike a JSON round-trip it keeps real Date objects and does not choke on Map or Set. Svelte added it for exactly this situation: handing state to something that does not expect a proxy, like structuredClone. Use the JSON trip when your data is plain and you want zero surprises about types. Use the snapshot when it is not.
The trap is that this only fires at the database boundary, so it is easy to fix one call site and get bitten by another later. Strip the proxy once, in the layer that owns the database:
// db.ts
export async function saveRecipe(recipe: Recipe): Promise<number> {
const clean = toPlainRecipe(recipe)
return db.recipes.add({ title: clean.title, recipe: clean /* ... */ } as SavedRecipe)
}
Every screen just calls saveRecipe(recipe) and no proxy ever reaches storage.
Reading is not a problem in the other direction. Dexie and IndexedDB hand you back plain objects. If you drop those into $state, Svelte re-wraps them in a fresh proxy, and the cycle stays clean.
Svelte 5 $state gives you a Proxy, not a plain object.
IndexedDB uses structured clone, which cannot copy a Proxy, so you get DataCloneError.
Strip the proxy at the database boundary in one shared function: a JSON round-trip for plain data (what I did), or $state.snapshot() if your state has dates, Maps, or Sets.
This was one of a few gotchas I hit building a local-first app with no server behind it. If you want the reasoning for why the whole thing runs on your device in the first place, I wrote that up here: Free forever is an architecture not a pricing decision.