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

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>
Why it happens
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.
The fix
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.
Put it in one place
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.
The short version
Svelte 5
$stategives 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.





