New Nov 25, 2025

Async SvelteKit Data and Side Effects

More Front-end Bloggers All from dbushell.com (blog) View Async SvelteKit Data and Side Effects on dbushell.com

Svelte(Kit) keeps pulling me back in! The remote function stuff is looking tasty. Wish I had that for my client work last year. I’ve got a personal project I’m tempted to refactor but I’ll wait for the API to stabilise.

In the meantime, I’m playing with a small SvelteKit app to read email newsletters.

Email inbox design mockup

The app has a basic API that wraps a Cloudflare SMTP bucket with two endpoints:

  1. List metadata for all emails
  2. Get data for a single email

In SvelteKit my single email template loads data asynchronously.

// email/[id]/+page.ts
import type {PageLoad} from "./$types";
import {fetchEmail} from "$lib/api";

export const load: PageLoad = ({fetch, params}) => {
  return {
    email: fetchEmail(fetch, params.id),
  };
};

fetchEmail is an async function that wraps the API and resolves an object with properties like: to, from, subject, body etc. When called client-side I cache the data in session storage because it’ll never change (there is no delete functionality yet).

In the Svelte template I use the await template syntax to handle loading state.

<script lang="ts">
  import type {PageProps} from './$types';

  const {data}: PageProps = $props();
</script>

{#await data.email}
  <h1>Loading email...</h1>
{:then email}
  <h1>{email.subject}</h1>
{/await}

Something like that, but spinnier.

Side Effects

I figured it would be helpful to mark emails as ā€œreadā€. I don’t want a server database so browser local storage will suffice. I can pass the URL id parameter and instantly set a key, but I want to wait until the full email data has loaded in case the request fails or is cancelled.

Because data.email is a promise I tried adding a callback at the top of the template.

<script lang="ts">
  import type {PageProps} from './$types';
  import {browser} from '$app/environment';

  const {data}: PageProps = $props();

  if (browser) {
    data.email.then((email) => {
      localStorage.setItem(email.id, "read");
    });
  }
</script>

This kinda works, but not really. This is not React where code is re-run ad infinitum. The code runs once. Only the first email on the initial page load is handled.

Svelte provides the $effect rune to mark such code as ā€œreactiveā€. Effects only run client-side so I can simply replace the browser conditional with an effect.

<script lang="ts">
  import type {PageProps} from './$types';

  const {data}: PageProps = $props();

  $effect(() => {
    data.email.then((email) => {
      localStorage.setItem(email.id, "read");
    });
  });
</script>

The Svelte compiler knows data.email is a dependency so it will execute the callback every time the data changes. There is no manual array like React’s useEffect hook.

This works better! But there are bugs here too.

If I navigate between emails before the previous one has loaded the old promise callback lingers. I risk marking an email as ā€œreadā€ that I didn’t actually read. The ā€˜effect’ of updating local storage is relatively harmless. Even so that’s a bug, and I’m also concerned about the use of stale props. The code smells all wrong.

One solution I tried was to use an abort controller.

<script lang="ts">
  import type {PageProps} from './$types';

  const {data}: PageProps = $props();
  let controller: AbortController;

  $effect(() => {
    controller?.abort();
    controller = new AbortController();
    const {signal} = controller;
    data.email.then((email) => {
      if (signal.aborted) return;
      localStorage.setItem(email.id, "read");
    });
  });
</script>

That passes the sniff test, I think?

The controller is not a reactive $state because that would trigger the effect to run again. The signal is destructured outside the promise so that I can reference the correct signal inside the resolve callback if the controller changes.

If I wanted to avoid $effect I could use afterNavigate instead. And the fancy pants controller could be replaced with a counter (I’m just vibing there).

<script lang="ts">
  import type {PageProps} from './$types';
  import {afterNavigate} from '$app/navigation';

  const {data}: PageProps = $props();
  let sequence = 0;

  afterNavigate(() => {
    let current = ++sequence;
    data.email.then((email) => {
      if (current !== sequence) return;
      localStorage.setItem(email.id, "read");
    });
  });
</script>

I feel like I’m over-thinking this and missing a trick somewhere.

Svelte has new experimental await that gave me an idea.

<script lang="ts">
  import type {PageProps} from './$types';

  const {data}: PageProps = $props();
  const id = $derived((await data.email).id);

  $effect(() => {
    localStorage.setItem(id, "read");
  });
</script>

Unfortunately this does not ā€˜cancel’ any emails I skip over that haven’t finished loading. All promises resolve and the effect callback cycles through every one.

Here’s an entirely different (bad) idea that works.

<script lang="ts">
  import type {PageProps} from './$types';

  const {data}: PageProps = $props();

  const getSubject = (email) => {
    localStorage.setItem(email.id, "read");
    return email.subject;
  };
</script>

{#await data.email}
  <h1>Loading email...</h1>
{:then email}
  <h1>{getSubject(email)}</h1>
{/await}

I worked it into the template logic because that can handle the ā€˜cancellation’. Of course, adding a side effect here is terrible code. I checked the Svelte docs and found the @attach syntax. Attach run effects when an element is mounted. Same idea but more explicit.

<script lang="ts">
	import type { Attachment } from 'svelte/attachments';
  import type {PageProps} from './$types';

  const {data}: PageProps = $props();

  const markRead: Attachment = (id) => {
    localStorage.setItem(id, "read");
  };
</script>

{#await data.email}
  <h1>Loading email...</h1>
{:then email}
  <h1 {@attach markRead(email.id)}>{email.subject}</h1>
{/await}

I’m not keen on this, but I’ve never used @attach before, maybe it’s fine?

A cleaner way would be to use two components to separate the async logic. The parent component goes back to basics.

<script lang="ts">
  import type {PageProps} from './$types';
  import Email from './email.svelte';

  const {data}: PageProps = $props();
</script>

{#await data.email}
  <h1>Loading email...</h1>
{:then email}
  <Email {email} />
{/await}

And the <Email> child component would be synchronous with an onMount.

<script lang="ts">
  import {onMount} from 'svelte';

  const {email} = $props();

  onMount(() => {
    localStorage.setItem(email.id, "read");
  });
</script>

<h1>{email.subject}</h1>

That looks better to me.

I can’t help but think I’m still missing a trick though! Does SvelteKit provide a better way to handle async page data effects that can be cancelled if data is replaced? Message me on the socials if you know!

(Don’t ask me how I’m retrieving the ā€œreadā€ state from local storage…)

Update (later that day…)

I successfully summoned Rich Svelte himself on Bluesky who shared a gem.

getAbortSignal()

Returns an AbortSignal that aborts when the current derived or effect re-runs or is destroyed. Must be called while a derived or effect is running.

Svelte documentation

This fixes my original $effect example and handles the controller logic.

<script lang="ts">
  import {getAbortSignal} from 'svelte';
  import type {PageProps} from './$types';

  const {data}: PageProps = $props();

  $effect(() => {
    const signal = getAbortSignal();
    data.email.then((email) => {
      if (signal.aborted) return;
      localStorage.setItem(email.id, "read");
    });
  });
</script>

That smells like excellent code to me!

It’s good to learn ā€œthe right wayā€ to do these things. For my exact use case here it’s unlikely an email would be marked incorrectly — I had to test with synthetic latency — and even if it was, whatever, I’ll probably remember what I’ve read. But next time the consequences might be more severe! Best not to leave bugs lying around.


Thanks for reading! Follow me on Mastodon and Bluesky. Subscribe to my Blog and Notes or Combined feeds.

Scroll to top