On this page
Svelte: an async `onMount` return is NOT cleanup
Svelte runs the value returned from `onMount` as cleanup on unmount — but only when it is a function. An `async` callback returns a Promise, so the cleanup is silently ignored.
Svelte turns the value you return from onMount into the component’s cleanup
function. It runs on unmount — but only when that value is a function. An async callback always returns a Promise, not a function, so Svelte quietly
ignores it and the cleanup never runs. No error, no warning. I ran into this
while adding a focus trap to a modal, and the way it fails is worth sharing
because nothing in the toolchain points at it.
The setup that surfaced it
I was adding an accessibility focus trap to the command-palette modal
(FuzzyFinder.svelte) on this site. When the palette opens it moves focus into
the search input; when it closes it should restore focus to whatever was
focused before it opened. Restoring focus on teardown is exactly what an onMount cleanup return is for, so the first version looked like this:
// Svelte's contract: the RETURN VALUE of onMount is the destroy callback,
// IF it is a function. An async function returns Promise<fn>, not fn.
onMount(async () => {
previouslyFocused = document.activeElement as HTMLElement | null;
await tick();
inputRef?.focus();
return () => previouslyFocused?.focus?.(); // ⟵ wrapped in a Promise → ignored
}); One detail broke it: that onMount was already async, because it awaits tick() before focusing the input. The moment a callback is async, its return
value is a Promise<() => void> — not the () => void Svelte is looking for. So
the restore function got wrapped in a Promise, Svelte saw a non-function return,
and the teardown silently never happened. Focus stayed trapped after the palette
closed.
Why it fails silently
This is the part I think is worth internalizing. Svelte’s contract for onMount is narrow: if the callback returns a function, run it on destroy. A Promise is not a function, so it falls outside the contract and is dropped without
complaint. The component still mounts and focuses correctly — only the teardown
is missing. In practice that shows up as leaked event listeners, focus that is
never restored, and timers that are never cleared: the class of bug that doesn’t
surface until much later.
The fix: move teardown to onDestroy
onDestroy has no return-value contract. It just runs when the component is
destroyed, so it does not care whether the mount logic is sync or async. Keep the
async setup in onMount and move the teardown to onDestroy:
import { onMount, onDestroy, tick } from "svelte";
let previouslyFocused: HTMLElement | null = null;
onMount(async () => {
previouslyFocused = document.activeElement as HTMLElement | null;
await tick();
inputRef?.focus();
});
// Runs on unmount regardless of onMount being async.
onDestroy(() => previouslyFocused?.focus?.()); If the setup is synchronous, returning the cleanup from onMount is fine —
the trap is specifically the async keyword turning the return into a Promise.
But onDestroy is unconditionally correct, which makes it the safer default
whenever there is any chance the mount logic awaits something.
The asymmetry that’s easy to mix up
The confusing part is that $effect does the opposite of an async onMount: a
function returned from $effect is run as cleanup, before each re-run and on
destroy. So “return a function to clean up” holds for $effect and for a synchronous onMount, but not for an async onMount. Mapping the four cases
side by side made it stick for me:
| Lifecycle call | Returned function runs as cleanup? |
|---|---|
onMount(() => fn) (sync) | Yes |
onMount(async () => fn) | No — returns a Promise, ignored |
onDestroy(fn) | N/A — fn itself is the teardown |
$effect(() => fn) | Yes — before each re-run and on destroy |
What made it hard to catch
The type checker and the build are both happy with the broken version. An async
function that returns a function is valid TypeScript, so svelte-check stays
green. I only caught it because the focus trap was exercised in a real browser
and focus visibly stayed stuck after closing the palette. A passing type check is
not evidence that your cleanup actually runs.
Takeaway
onMount(fn)cleanup works only whenfnsynchronously returns a function.async () => { …; return cleanup }returnsPromise<cleanup>, which Svelte ignores — and it does so without an error.- Reach for
onDestroywhenever the mount logic must beasync(awaitingtick(), a dynamicimport(), a fetch, and so on). - Don’t let a green
svelte-checkconvince you teardown happens — confirm cleanup behavior in the browser.