Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 035 / 100

Streaming data in SvelteKit: what developers should know

Show pages before slow data finishes.

Expona Admin6 min read

You've likely experienced that frustrating moment: a web page renders quickly, but critical data is nowhere to be found. Streaming data in SvelteKit: what developers should know is about changing that dynamic, letting your users see something useful while your backend fetches what matters. This pattern, built into SvelteKit's server load system, transforms perceived performance by separating fast-rendering static content from slower asynchronous operations.

How streaming data changes page delivery

Traditional SvelteKit page loads wait for every promise in your server load function to resolve before sending any HTML to the browser. If one API call takes 3 seconds and another takes 800ms, the user stares at a blank screen for the full 3 seconds. Streaming flips this: SvelteKit sends your initial HTML shell immediately, then streams in data as it arrives.

This isn't a magical framework feature that requires no thought. It's a deliberate architectural choice you make by returning promises from server loads rather than awaiting them. The browser receives something to render in milliseconds, and the page becomes interactive before all data is complete.

Consider an e-commerce product page. The product title, price, and basic description might load from a cache in 50ms. Customer reviews could take 2 seconds from your database. Without streaming, users wait 2 seconds. With streaming, they see the buyable product in 50ms and reviews appear as they load.

Return promises from server loads

The core technique is straightforward: instead of awaiting every async operation, return them as properties in an object. SvelteKit will resolve these promises and stream the values to your component.

typescript
// src/routes/products/[id]/+page.server.ts
import { error } from '@sveltejs/kit';

export async function load({ params }) {
  const productId = params.id;

  // Fast query - runs immediately and completes quickly
  const product = fetch(`/api/products/${productId}`)
    .then(r => r.json());

  // Slow query - takes time but we don't await it
  const reviews = fetch(`/api/products/${productId}/reviews`)
    .then(r => r.json());

  // Immediate operation - user info from session
  const userPreferences = getCurrentUserPrefs();

  return {
    product,    // Promise
    reviews,    // Promise
    userPreferences  // Resolved value
  };
}

Notice we're not using await on product or reviews. They're promises that will resolve in the browser, not the server. SvelteKit handles this streaming transparently—it serializes the promises and resolves them client-side.

Using {#await} in the page template

On the client side, your component receives data with a mix of resolved values and pending promises. Svelte's {#await} block handles this elegantly:

typescript
// src/routes/products/[id]/+page.svelte
<script>
  export let data;
</script>

<div class="product-page">
  <!-- User prefs already resolved -->
  <div class="theme-{data.userPreferences.theme}">
    <!-- Product might still be loading -->
    {#await data.product}
      <div class="skeleton product-skeleton" />
    {:then product}
      <h1>{product.name}</h1>
      <p class="price">${product.price}</p>
      <button>Add to cart</button>
    {:catch error}
      <p class="error">Failed to load product</p>
    {/await}

    <!-- Reviews section -->
    {#await data.reviews}
      <div class="reviews-skeleton">
        <div class="skeleton" />
        <div class="skeleton" />
      </div>
    {:then reviews}
      <section class="reviews">
        <h2>Customer Reviews</h2>
        {#each reviews as review (review.id)}
          <article>
            <strong>{review.author}</strong>
            <p>{review.text}</p>
            <span class="rating">{review.rating}/5</span>
          </article>
        {/each}
      </section>
    {:catch}
      <p>Could not load reviews at this time</p>
    {/await}
  </div>
</div>

Each {#await} block shows a loading state first, then the resolved content. If a promise rejects, the {:catch} block renders. This gives users immediate visual feedback: they see skeletons or spinners rather than a blank page.

Achieving fast first paint

Fast first paint is the primary win here. Your browser can paint HTML to the screen almost immediately, before all backend calls finish. The exact speed depends on what's in your initial server load response, but you're looking at sub-100ms renders instead of waiting for your slowest API.

Here's the practical impact: a user on a 4G connection with a 2-second API latency will see your page appear in ~50ms instead of ~2050ms. They can start reading content, clicking on other sections, or scrolling while data streams in. Perception of performance improves dramatically.

To maximize this benefit, keep your fast-path data minimal. Return only what's needed for the initial render: page title, layout elements, skeleton loaders, and any pre-cached data. Push everything else into promises.

typescript
// Good: minimal fast-path
export async function load({ params }) {
  const page = {
    title: 'Product Page',
    layout: 'standard'
  };

  const productData = fetchProduct(params.id); // Promise
  const relatedItems = fetchRelated(params.id); // Promise

  return {
    page,
    productData,
    relatedItems
  };
}

// Bad: waiting on everything
export async function load({ params }) {
  const productData = await fetchProduct(params.id);
  const relatedItems = await fetchRelated(params.id);
  const analytics = await trackPageView();

  return { productData, relatedItems, analytics };
}

When to use streaming for non-critical data

Streaming works best for non-critical data—information that enhances the page but isn't required for basic functionality. Reviews, recommendations, social proof, analytics, and supplementary details are perfect candidates.

For critical data—product price, availability, core information needed for purchase—you should still await these in your server load. A user shouldn't add an out-of-stock item to their cart because streaming made stock status load late. The rule is simple: if it affects functionality or purchase decisions, load it fast. If it's enrichment, stream it.

In a ride-hailing app, the pickup location, destination, and estimated fare are critical—await them. Driver rating history and past trip details are streaming candidates. In a CRM, contact name and email are critical. Activity timeline and related records can stream.

Streaming data is not about loading everything asynchronously. It's about intelligent separation: fast-path synchronous data for immediate render, async data for progressive enhancement.

Common mistakes

Mistake 1: Streaming everything. If you return all promises, nothing renders until at least one resolves. Ensure some data completes fast enough for a meaningful first paint. If your fastest data takes 500ms, you've defeated the purpose.

Mistake 2: Not handling errors in templates. When using {#await}, always include a {:catch} block. Unhandled promise rejections create a poor experience. Test what happens when your APIs fail and ensure your UI degrades gracefully.

Mistake 3: Ignoring skeleton loading states. A blank div while data loads feels slower than a skeleton screen. Show visual placeholders during the pending state. This keeps users confident the page is working.

Mistake 4: Mixing sync and async inconsistently. If some pages await all data and others stream everything, you'll create unpredictable performance. Establish a pattern in your team: what's critical, what's not, and stick to it across the app.

Mistake 5: Forgetting about client-side performance. Streaming helps server-to-browser latency, but it doesn't eliminate heavy rendering. If your reviews component renders 10,000 items at once, the browser still stalls. Combine streaming with pagination or virtualization for best results.

Key takeaways

  • Return promises from server loads instead of awaiting them—SvelteKit will stream their values to your client.
  • Use {#await} blocks in templates to handle pending, resolved, and rejected states for each promise.
  • Fast first paint comes from separating critical data (awaited) from enrichment data (streamed).
  • Streaming works best for non-critical content: reviews, recommendations, analytics, and supplementary details.
  • Always include error handling and loading states in your template for a polished user experience.
  • Balance streaming with good skeleton design and avoid streaming everything at once—maintain a meaningful initial render.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles