Skip to content

svelte-time <Time relative live> leaks a setInterval per SSR render, pinning every SvelteKit request (adapter-node heap OOM)

SvelteKit 2 app on adapter-node (Node 22, Svelte 4 components) hit V8 heap OOM on 512MB instances roughly every 4 days, always during SEO crawls. Looked like SSR working-set pressure from large inlined load payloads; converting universal loads to server loads shrank pages but did not explain growth. With node --expose-gc and a gc() before each heartbeat, post-GC heapUsed at idle rose monotonically across identical 900-request batches (78 -> 95 -> 109 -> 125 MB, about 17KB per request), so it was a leak, not working set. Nothing in our server code held per-request state in module-level maps. Side symptom: the local node build/index.js server ignored SIGTERM after load and needed SIGKILL. Which component or library retains every SSR request?

1 solution
ranked by outcome — not votes
Accepted

Root cause: svelte-time 0.9.0 Time.svelte starts its live-update timer in a reactive statement and clears it only in onMount's cleanup:

$: if (relative && live !== false) interval = setInterval(() => { formatted = dayjs(timestamp).from(); }, 60000);
onMount(() => () => clearInterval(interval));

Reactive statements run during SSR; onMount never does. So every server render of <Time relative live /> (or live={true}) leaves a 60s setInterval running forever. On Node 22 each Timeout holds an AsyncContextFrame (Timeout[kAsyncContextFrame]), and that frame pins the whole SvelteKit request event: event.platform.req (IncomingMessage + Socket + raw headers), the cookies/setHeaders/fetch closures, transformPageChunk options, and Sentry's isolation scope with its normalized request. The intervals also keep firing dayjs().from() every minute (CPU grows with uptime) and keep the event loop alive, which is why SIGTERM did not exit the process.

How it showed up in a heap snapshot diff (two snapshots via --heapsnapshot-signal=SIGUSR2, 900 requests apart): Timeout +4.5 per request, IncomingMessage and Socket exactly +1 per request, Object/Context/closures growing; 5858 of 6013 Timeouts had _onTimeout in the server chunk that inlines svelte-time. Retaining path: timer list -> Timeout -> AsyncContextFrame -> request event.

Fix: only enable live updates in the browser:

<script>
  import { browser } from '$app/environment';
  import Time from 'svelte-time';
  export let iso_time;
</script>
<Time relative timestamp={iso_time} live={browser} />

SSR markup is identical (relative text renders the same), the client still updates live and clears the interval on destroy. After the fix the same snapshot protocol was flat (+0.9MB, V8 code only; Timeouts 158 total instead of 6013).

General rule: any Svelte component that calls setInterval/setTimeout/addEventListener in a $: statement or at script top level and cleans up only in onMount/onDestroy-from-onMount leaks on every SSR render; under Node's AsyncContextFrame each leaked timer retains the entire request. Audit third-party components the same way (grep -n setInterval node_modules/<pkg>/**/*.svelte).