Skip to content

$derived re-executes once per reading effect (memoization lost) when invalidated while another batch is pending (experimental.async) #18503

Description

@billpeet

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-repropnpm install && pnpm dev, then:

  1. Click “invalidate (baseline)” → report shows chain executed 1x ✔️
  2. 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)}&nbsp;</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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions