Skip to main content
doc0blade47/honodocumentation home
Sign in

JSX Streaming and Suspense

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Hono provides experimental JSX streaming and Suspense capabilities via renderToReadableStream and the Suspense component, enabling server-rendered HTML chunks to be flushed progressively to clients. By resolving asynchronous child nodes and callbacks across execution phases, it allows fallback content placeholders to be emitted immediately while pending components resolve out-of-order.

Sources: src/jsx/streaming.ts:42-133, src/jsx/streaming.ts:142-201

Stream Generation and ReadableStream Pipeline

Hono exposes stream generation functions across both core JSX modules and React DOM server compatibility layers. The primary streaming entry point is renderToReadableStream, defined in src/jsx/streaming.ts, which accepts an initial content payload and an optional error handler before constructing a standard ReadableStream<Uint8Array> instance. The hono/jsx/server module (src/jsx/dom/server.ts) re-exports an asynchronous variant of renderToReadableStream that delegates directly to the core streaming implementation while parsing configuration options such as onError.

export const renderToReadableStream = (
  content: HtmlEscapedString | JSXNode | Promise<HtmlEscapedString>,
  onError: (e: unknown) => string | void = console.trace
): ReadableStream<Uint8Array> => {
  let cancelled = false
  const reader = new ReadableStream<Uint8Array>({
    async start(controller) {
      try {
        if (content instanceof JSXNode) {
          content = content.toString() as HtmlEscapedString | Promise<HtmlEscapedString>
        }
        const context = typeof content === 'object' ? content : {}
        const resolved = await resolveCallback(
          content,
          HtmlEscapedCallbackPhase.BeforeStream,
          true,
          context
        )
        if (!cancelled) {
          controller.enqueue(textEncoder.encode(resolved))
        }
// ...

Sources: src/jsx/streaming.ts:142-163

The pipeline transforms JSX nodes, escaped strings, or promises into binary chunks using a TextEncoder instance. When renderToReadableStream initializes, it checks whether the content parameter is an instance of JSXNode, invoking its .toString() method to yield an escaped string or promise. It then resolves callbacks through HtmlEscapedCallbackPhase.BeforeStream before enqueuing the encoded byte array into the readable stream controller.

Function / ModuleTarget Runtime / APIParametersReturn Type
renderToReadableStream (src/jsx/streaming.ts)Core Hono JSX Streamingcontent, onErrorReadableStream<Uint8Array>
renderToReadableStream (src/jsx/dom/server.ts)React DOM Server Compatibilityelement, optionsPromise<ReadableStream<Uint8Array>>
renderToString (src/jsx/dom/server.ts)Synchronous DOM Serverelement, optionsstring

Sources: src/jsx/streaming.ts:142-145, src/jsx/dom/server.ts:21-30, src/jsx/dom/server.ts:50-63

Note

Options passed to renderToReadableStream in hono/jsx/server other than onError (such as identifierPrefix, namespaceURI, nonce, bootstrapScripts, and progressiveChunkSize) trigger a console warning stating that those options are not supported yet.

Sources: src/jsx/dom/server.ts:54-56

Warning

Synchronous rendering via renderToString in hono/jsx/server throws an error if an asynchronous component is encountered, as async components cannot be resolved without a Promise-based execution context.

Sources: src/jsx/dom/server.ts:26-28

Suspense Architecture and Placeholder Inlining

Component Structure and Context Resolution

The Suspense component in src/jsx/streaming.ts acts as an asynchronous boundary resolver for JSX tree rendering. When evaluated, it wraps child elements and checks context for script nonces if provided through custom providers or context maps. Children passed to Suspense are normalized into an array, and the component initializes a data stack node utilizing DOM_STASH to support the execution of hooks and asynchronous boundaries.

  const stackNode = { [DOM_STASH]: [0, []] } as unknown as NodeObject
  const popNodeStack = (value?: unknown) => {
    buildDataStack.pop()
    return value
  }

Sources: src/jsx/streaming.ts:55-59

Fallback Marker Emission and Promise Detection

During synchronous child stringification, if a child evaluation throws a Promise (triggered by an asynchronous component or data-fetching hook), the Suspense boundary captures the promise and switches into streaming mode.

  try {
    stackNode[DOM_STASH][0] = 0
    buildDataStack.push([[], stackNode])
    resArray = children.map((c) =>
      c == null || typeof c === 'boolean' ? '' : c.toString()
    ) as HtmlEscapedString[]
  } catch (e) {
    if (e instanceof Promise) {
      resArray = [
        e.then(() => {
          stackNode[DOM_STASH][0] = 0
          buildDataStack.push([[], stackNode])
          return childrenToString(children as Child[]).then(popNodeStack)
        }),
      ] as Promise<HtmlEscapedString[]>[]
    } else {
      throw e
    }
  } finally {
    popNodeStack()
  }

Sources: src/jsx/streaming.ts:61-81

When asynchronous promises are detected in resArray, a unique numeric identifier is assigned using the module-level suspenseCounter. The fallback content is serialized and wrapped in an HTML comment boundary and a template anchor tag using the format <template id="H:${index}"></template>${fallbackStr}<!--/$-->.

  if (resArray.some((res) => (res as {}) instanceof Promise)) {
    const index = suspenseCounter++
    const fallbackStr = await fallback.toString()
    return raw(`<template id="H:${index}"></template>${fallbackStr}<!--/$-->`, [
      ...(fallbackStr.callbacks || []),
      ({ phase, buffer, context }) => {
// ...

Sources: src/jsx/streaming.ts:83-88

Note

If all child nodes resolve synchronously without throwing a Promise, Suspense bypasses streaming mechanics entirely, returning the concatenated string result via raw(resArray.join('')).

Sources: src/jsx/streaming.ts:130-132

Warning

The DOM-specific counterpart Suspense exported from src/jsx/dom/components.ts registers a DOM_ERROR_HANDLER that catches promise rejections differently, attaching a .finally(retry) hook for client-side DOM reconciliation rather than server-side template streaming.

Sources: src/jsx/dom/components.ts:25-38

Out-of-Order Chunk Resolution and Replacement

Callback Phases and Stream Buffering

When asynchronous promises within a Suspense boundary complete, the callback mechanism handles chunk resolution based on the HtmlEscapedCallbackPhase. During the initial BeforeStream phase, callbacks return early without altering the stream output. Once the stream begins, Promise.all(resArray) resolves all pending asynchronous children, flattening the resulting HTML array into a unified string.

        return Promise.all(resArray).then(async (htmlArray) => {
          htmlArray = htmlArray.flat()
          const content = htmlArray.join('')
          if (buffer) {
            buffer[0] = buffer[0].replace(
              new RegExp(`<template id="H:${index}"></template>.*?<!--/\\$-->`),
              content
            )
          }

Sources: src/jsx/streaming.ts:92-100

If an active chunk buffer exists (buffer), the runtime replaces the placeholder region—bounded by <template id="H:${index}"></template> and the closing comment <!--/$-->—directly within the buffered content string. When no buffer is present, the resolved content is wrapped in a target template and paired with an inline swapping script for client-side insertion.

Note

The buffer check allows server-side renderers to catch up and inline resolved components directly into preceding stream chunks if resolution finishes before the chunk is flushed to the network controller.

Sources: src/jsx/streaming.ts:95-103

Client-Side DOM Swapping Scripts

When streaming responses without an active pre-buffer, Hono injects an IIFE execution script immediately following the resolved chunk container. This script locates the placeholder anchor via document.getElementById('H:${index}'), traverses sibling nodes up to the closing comment boundary (/^\$/), and replaces the fallback content with the new template content.

          let html = buffer
            ? ''
            : `<template data-hono-target="H:${index}">${content}</template><script${
                nonce ? ` nonce="${nonce}"` : ''
              }>
((d,c,n) => {
c=d.currentScript.previousSibling
d=d.getElementById('H:${index}')
if(!d)return
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
d.replaceWith(c.content)
})(document)
</script>`

Sources: src/jsx/streaming.ts:101-113

Warning

If a script nonce is resolved from context and configured, it is appended directly to the injected <script> tag. Omission of a valid nonce under strict Content Security Policy (CSP) headers will block the swapping script from executing in the browser.

Sources: src/jsx/streaming.ts:103-105

Nested Callback Resolution and Recursion

Resolved HTML chunks may contain their own nested callbacks. The callback processor inspects each resolved fragment for embedded callback arrays, recursively resolving nested stream phases before returning the final raw escaped string block.

          const callbacks = htmlArray
            .map((html) => (html as HtmlEscapedString).callbacks || [])
            .flat()
          if (!callbacks.length) {
            return html
          }
 
          if (phase === HtmlEscapedCallbackPhase.Stream) {
            html = await resolveCallback(html, HtmlEscapedCallbackPhase.BeforeStream, true, context)
          }
 
          return raw(html, callbacks)

Sources: src/jsx/streaming.ts:115-128

Client-Side DOM Suspense Compatibility

Suspense Boundary Definition and Promise Detection

In client-side DOM rendering environments provided by hono/jsx/dom, the Suspense component wraps child trees using Fragment and attaches a custom error handler via the DOM_ERROR_HANDLER constant. When rendering client-side nodes, asynchronous operations throw promises that trigger this handler. The boundary inspects the caught error to determine whether it represents an ongoing asynchronous task or an unhandled exception.

export const Suspense: FC<PropsWithChildren<{ fallback: any }>> = (({
  children,
  fallback,
}: any) => {
  const res = Fragment({ children })
  ;(res as any)[DOM_ERROR_HANDLER] = (err: any, retry: () => void) => {
    if (!(err instanceof Promise)) {
      throw err
    }
    err.finally(retry)
    return fallback
  }
  return res
}) as any

Sources: src/jsx/dom/components.ts:25-38

Note

If the caught error is not an instance of a Promise, the Suspense handler rethrows the error immediately, passing control up to the nearest ErrorBoundary or global handler.

Sources: src/jsx/dom/components.ts:31-33

ErrorBoundary Integration and Fallback Rendering

Client-side component trees also utilize ErrorBoundary to manage non-promise exceptions and trigger fallback rendering views. The error boundary wraps children in a fragment and attaches a DOM_ERROR_HANDLER that invokes optional error callbacks and resolves fallback UI nodes.

export const ErrorBoundary: FC<
  PropsWithChildren<{
    fallback?: Child
    fallbackRender?: FallbackRender
    onError?: ErrorHandler
  }>
> = (({ children, fallback, fallbackRender, onError }: any) => {
  const res = Fragment({ children })
  ;(res as any)[DOM_ERROR_HANDLER] = (err: any) => {
    if (err instanceof Promise) {
      throw err
    }
    onError?.(err)
    return fallbackRender?.(err) || fallback
  }
  return res
}) as any

Sources: src/jsx/dom/components.ts:7-23

Important

Unlike Suspense, which catches promises and schedules a retry upon resolution, ErrorBoundary explicitly rethrows any encountered Promise instances, allowing asynchronous boundaries to handle data-fetching suspensions further up or down the tree.

Sources: src/jsx/dom/components.ts:16-18

Lifecycle Coordination and Error Handling

Error Catching and Recovery in Readable Streams

During the execution of renderToReadableStream, asynchronous chunk generation and callback resolution can fail. When a promise returned by a callback rejects, the error handling logic catches the exception, logs it via console.log, invokes the user-provided onError callback function, and returns an empty string to prevent stream termination.

        const then = (promise: Promise<string>) => {
          callbacks.push(
            promise
              .catch((err) => {
                console.log(err)
                onError(err)
                return ''
              })
              .then(async (res) => {

Sources: src/jsx/streaming.ts:167-175

Caution

The default error handler for renderToReadableStream is console.trace, which outputs stack traces to standard output. Production environments should supply a custom onError handler to manage error telemetry and status propagation properly.

Sources: src/jsx/streaming.ts:144-144

Component-Level Error Boundaries and Suspense Retry Logic

Client-side DOM component rendering coordinates errors through boundaries defined with DOM_ERROR_HANDLER. The Suspense component distinguishes between pending promises and synchronous exceptions. If an error is a promise, it attaches a finally handler to trigger a retry execution, while displaying the fallback UI. Conversely, an ErrorBoundary executes an optional onError callback and renders either fallbackRender or a static fallback node.

export const ErrorBoundary: FC<
  PropsWithChildren<{
    fallback?: Child
    fallbackRender?: FallbackRender
    onError?: ErrorHandler
  }>
> = (({ children, fallback, fallbackRender, onError }: any) => {
  const res = Fragment({ children })
  ;(res as any)[DOM_ERROR_HANDLER] = (err: any) => {
    if (err instanceof Promise) {
      throw err
    }
    onError?.(err)
    return fallbackRender?.(err) || fallback
  }
  return res
}) as any

Sources: src/jsx/dom/components.ts:7-23

Note

ErrorBoundary automatically rethrows any caught error if it is an instance of a Promise, ensuring that async data-loading errors are correctly handled by parent Suspense boundaries rather than being swallowed as rendering faults.

Sources: src/jsx/dom/components.ts:16-18

Was this page helpful?