sveltejs / svelte · Issue No. 18503
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.
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):
<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>
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)
annoyance
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.