Deferred Data

Furin supports deferred data streaming — returning a mix of synchronous values and Promises from a page loader so the HTML shell can be sent immediately while slow data streams in later.

Why defer?

In a standard loader, every await blocks the entire render. If one query takes 600 ms, the user sees a blank page until everything resolves.

With defer(), scalar fields are embedded in the initial HTML shell immediately, and Promise-valued fields are streamed as late <script> chunks that resolve on the client. The shell renders instantly; slow data populates when ready.

Using defer()

defer() is available from @teyik0/furin/client. It wraps the loader return value and tells Furin to split scalar fields from Promise fields.

src/pages/dashboard/index.tsx
import { defer } from "@teyik0/furin/client"
import { route } from "./_route"

export default route.page({
  loader: async () => {
    const fast = { title: "Dashboard" }
    const slow = fetchAnalytics()

    return defer({
      ...fast,
      stats: slow, // Promise — streamed later
    })
  },
  component: ({ title, stats }) => (
    <div>
      <h1>{title}</h1>
      <Suspense fallback={<Spinner />}>
        <Await resolve={stats}>
          {(data) => <AnalyticsChart data={data} />}
        </Await>
      </Suspense>
    </div>
  ),
})

<Await>

<Await> consumes a deferred Promise. It uses React 19's use() to suspend the boundary until the Promise settles, then calls the render-prop children with the resolved value.

tsx
import { Await } from "@teyik0/furin/client"

<Await resolve={stats}>
  {(data) => <AnalyticsChart data={data} />}
</Await>

<Await> must be wrapped in a <Suspense> boundary so React has a fallback to show while the Promise is pending.

Error handling

When a deferred Promise rejects, <Await> catches it with an internal error boundary. Provide errorElement to render a fallback:

tsx
<Await resolve={stats} errorElement={<ErrorCard />}>
  {(data) => <AnalyticsChart data={data} />}
</Await>

Inside errorElement, call useAsyncError() to read the rejection reason:

tsx
import { useAsyncError } from "@teyik0/furin/client"

function ErrorCard() {
  const error = useAsyncError()
  return <p>Failed to load stats: {String(error)}</p>
}

If errorElement is omitted, the error propagates to the nearest React error boundary.

useAsyncValue()

As an alternative to the render-prop API, you can use useAsyncValue() inside a component rendered as children of <Await>:

tsx
import { Await, useAsyncValue } from "@teyik0/furin/client"

function Chart() {
  const data = useAsyncValue<AnalyticsData>()
  return <AnalyticsChart data={data} />
}

<Await resolve={stats} errorElement={<ErrorCard />}>
  <Chart />
</Await>

Deferring in layouts

defer() works in any loader of the route tree — page and layout/route loaders defined via createRoute({ loader }). A layout can ship its shell (nav, sidebar) immediately while a slow widget streams in the background:

src/pages/dashboard/_route.tsx
import { createRoute, defer } from "@teyik0/furin/client"
import { Suspense } from "react"
import { Await } from "@teyik0/furin/client"

export const route = createRoute({
  loader: () =>
    defer({
      user: "alice",                  // sync — embedded in the initial shell
      widgets: fetchSidebarWidgets(), // Promise — streamed when it resolves
    }),
  layout: ({ children, user, widgets }) => (
    <div>
      <nav>Hello {user}</nav>
      <aside>
        <Suspense fallback={<WidgetSkeleton />}>
          <Await resolve={widgets}>
            {(list) => <WidgetList widgets={list} />}
          </Await>
        </Suspense>
      </aside>
      <main>{children}</main>
    </div>
  ),
})

The deferred field is exposed to the layout component as a Promise, exactly like a deferred field would be exposed to a page component. Promises from layouts and pages are streamed together — there is no separate transport.

If both a layout and its page defer() a field with the same key, the page wins (last-spread semantics), matching the merge order used for synchronous loader data.

Important restrictions

  • Always wrap <Await> in <Suspense> — without a Suspense boundary, React has no fallback to render during the pending phase.
  • SPA navigation — deferred Promises are also supported on client-side navigation; Furin fetches them via /_furin/data and reconstructs the same Promise-based props.

Full example

src/pages/board/[id].tsx
import { defer, Await } from "@teyik0/furin/client"
import { route } from "./_route"
import { Suspense } from "react"

export default route.page({
  loader: async ({ params }) => {
    const board = await getBoard(params.id)        // fast — blocks render
    const comments = fetchComments(params.id)        // slow — deferred

    return defer({
      board,
      comments,
    })
  },

  component: ({ board, comments }) => (
    <div>
      <h1>{board.name}</h1>
      <Suspense fallback={<p>Loading comments…</p>}>
        <Await
          resolve={comments}
          errorElement={<p>Could not load comments.</p>}
        >
          {(list) => <CommentList comments={list} />}
        </Await>
      </Suspense>
    </div>
  ),
})

Limitations

  • defer() is SSR-only. A loader that returns defer() on a route rendered in ssg or isr mode throws an error: the HTML is pre-rendered and cached, so the deferred fields would be missing from the hydration payload and <Await> would crash on the client. Either return the data directly (await it inside the loader) or switch the route to SSR mode.

Comparison

ApproachWhen to use
Normal loaderAll data is fast (< 50 ms) or required before any paint
defer()Some data is slow and can appear after the shell

Comments