Describe the bug
With compilerOptions.experimental.async enabled, if state is invalidated while an async derived is still pending (so more than one Batch is alive), $derived values lose their once-per-flush memoization: a derived chain is re-executed once for every effect that reads it instead of once.
With N template cells reading a shared derived chain, one invalidation executes the chain ~8×N times. Measured with the repro below:
| scenario |
chain executions per invalidation |
| invalidate, no async pending |
1 |
| invalidate while async pending, 100 reader cells |
800 |
| invalidate while async pending, 300 reader cells |
2400 |
Real-world impact: in a production app (~1000 effects reading a derived index chain that costs ~30 ms to rebuild), a periodic background refresh (async remote query → pending batch) plus a state reseed produced a 30–40 second main-thread freeze — the flush re-executed the chain per effect.
Reproduction
https://github.com/billpeet/svelte-async-batch-derived-repro — pnpm install && pnpm dev, then:
- Click “invalidate (baseline)” → report shows chain executed 1x ✔️
- Click “invalidate while async pending” → the identical invalidation reports chain executed 2400x ❌
The whole repro is one component (src/Inner.svelte, wrapped in a svelte:boundary):
<script>
const ROWS = 300;
let executions = 0;
class Session {
data = $state({ version: 0 });
index = $derived.by(() => {
executions += 1;
return new Map([['v', this.data.version]]); // fresh object => !equals
});
list = $derived.by(() => [...this.index.values()]);
read(i) {
return this.list[0] + i;
}
}
const session = new Session();
let asyncTrigger = $state(0);
let report = $state('—');
async function slow(t) {
if (t > 0) await new Promise((r) => setTimeout(r, 3000));
return t;
}
function measure(label) {
executions = 0;
session.data = { version: session.data.version + 1 };
setTimeout(() => (report = `${label}: chain executed ${executions}x`), 200);
}
</script>
<p>{report}</p>
<p>async value: {await slow(asyncTrigger)}</p>
<button onclick={() => measure('baseline (no pending async)')}>invalidate (baseline)</button>
<button
onclick={() => {
asyncTrigger += 1; // async derived goes pending → batch stays alive ~3s
setTimeout(() => measure('while async pending'), 100);
}}>invalidate while async pending</button>
<div>
{#each Array.from({ length: ROWS }) as _, i (i)}
<span>{session.read(i)} </span>
{/each}
</div>
Logs
System Info
Svelte: 5.56.4 (latest), compilerOptions: { runes: true, experimental: { async: true } }
@sveltejs/vite-plugin-svelte: 7.1.2
Vite: 8.1.3
OS: Windows 11, reproduced in Chrome + headless Chromium (Playwright)
Severity
annoyance
Describe the bug
With compilerOptions.experimental.async enabled, if state is invalidated while an async derived is still pending (so more than one Batch is alive), $derived values lose their once-per-flush memoization: a derived chain is re-executed once for every effect that reads it instead of once.
With N template cells reading a shared derived chain, one invalidation executes the chain ~8×N times. Measured with the repro below:
Real-world impact: in a production app (~1000 effects reading a derived index chain that costs ~30 ms to rebuild), a periodic background refresh (async remote query → pending batch) plus a state reseed produced a 30–40 second main-thread freeze — the flush re-executed the chain per effect.
Reproduction
https://github.com/billpeet/svelte-async-batch-derived-repro —
pnpm install && pnpm dev, then:The whole repro is one component (src/Inner.svelte, wrapped in a svelte:boundary):
Logs
System Info
Svelte: 5.56.4 (latest), compilerOptions: { runes: true, experimental: { async: true } } @sveltejs/vite-plugin-svelte: 7.1.2 Vite: 8.1.3 OS: Windows 11, reproduced in Chrome + headless Chromium (Playwright)Severity
annoyance