If you're building anything real-time in Svelte 5 — SSE, WebSockets, polling — you will probably hit this.
The Setup
You have a reactive state object and an $effect that sets up a listener. The listener pushes new data into the state:
let data = $state({ messages: [] });
$effect(() => {
const source = new EventSource('/api/events');
source.onmessage = (e) => {
data.messages = [...data.messages, JSON.parse(e.data)];
};
return () => source.close();
});
This looks fine. It blows up.
What Happens
The $effect reads data.messages (in the spread) and writes data.messages (the assignment). Svelte 5's reactivity tracks every read inside an effect. When the write invalidates what was read, the effect re-runs. Which sets up a new EventSource. Which fires. Which writes. Which re-runs the effect.
Infinite loop. Your browser tab dies.
The Fix
One function: untrack().
import { untrack } from 'svelte';
let data = $state({ messages: [] });
$effect(() => {
const source = new EventSource('/api/events');
source.onmessage = (e) => {
untrack(() => {
data.messages = [...data.messages, JSON.parse(e.data)];
});
};
return () => source.close();
});
untrack() tells Svelte: execute this code, but don't treat any reads inside it as dependencies of the current effect. The effect sets up the listener once. Messages still flow into reactive state. Components still re-render when data.messages changes. The effect just doesn't re-run.
The Principle
An $effect should either read reactive state or write reactive state. When it does both to the same value, you get a feedback loop. This shows up anywhere you have long-lived subscriptions that interact with state:
- EventSource / SSE listeners
- WebSocket
onmessagehandlers setIntervalcallbacks- Any callback that both reads and mutates a
$statevalue
The rule: wrap the write path in untrack() if the effect also reads from the same source.
Svelte 4 didn't have this problem because stores were explicitly subscribed. Runes are more automatic, which is usually great — until the automation creates a cycle.