Skip to main content
Hono’s JSX streaming allows you to send HTML to the client progressively, improving perceived performance by showing content as it becomes available. This is especially useful for pages with slow data fetching.

What is Streaming?

Streaming HTML means sending parts of the page to the browser before the entire page is ready. This allows:
  • Faster initial render - Show content immediately
  • Better perceived performance - Users see progress
  • Improved UX - No blank page while waiting
  • Efficient resource usage - Start rendering before all data loads
Streaming is an experimental feature. The API may change in future versions.

Basic Streaming

Use renderToReadableStream() to stream JSX content:
import { Hono } from 'hono'
import { renderToReadableStream } from 'hono/jsx/streaming'
import { stream } from 'hono/streaming'

const app = new Hono()

app.get('/stream', (c) => {
  const stream = renderToReadableStream(
    <html>
      <body>
        <h1>Streaming Content</h1>
        <p>This is sent to the client immediately.</p>
      </body>
    </html>
  )
  
  return c.body(stream, {
    headers: {
      'Content-Type': 'text/html; charset=UTF-8',
      'Transfer-Encoding': 'chunked',
    },
  })
})

export default app
Source: src/jsx/streaming.ts:142-216

Suspense Component

The Suspense component allows you to show a fallback while async content loads:
import { Suspense } from 'hono/jsx/streaming'

const fetchUserData = async (id: string) => {
  await new Promise(resolve => setTimeout(resolve, 2000))
  return { name: 'John Doe', email: '[email protected]' }
}

const UserProfile = async ({ id }: { id: string }) => {
  const user = await fetchUserData(id)
  
  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}

const Page = ({ userId }: { userId: string }) => {
  return (
    <html>
      <body>
        <h1>User Profile</h1>
        <Suspense fallback={<div>Loading user data...</div>}>
          <UserProfile id={userId} />
        </Suspense>
      </body>
    </html>
  )
}

app.get('/user/:id', (c) => {
  const userId = c.req.param('id')
  const stream = renderToReadableStream(<Page userId={userId} />)
  
  return c.body(stream, {
    headers: {
      'Content-Type': 'text/html; charset=UTF-8',
    },
  })
})
Source: src/jsx/streaming.ts:42-133

How Suspense Works

When you use Suspense:
  1. The fallback is rendered immediately and sent to the client
  2. The async component starts loading in the background
  3. Once loaded, the actual content is sent to the client
  4. Client-side JavaScript replaces the fallback with the real content
// Initial HTML sent to client
<template id="H:0"></template>
<div>Loading user data...</div>
<!--/$-->

// After data loads, this is sent:
<template data-hono-target="H:0">
  <div class="user-profile">
    <h2>John Doe</h2>
    <p>[email protected]</p>
  </div>
</template>
<script>
  // JavaScript to replace fallback with content
</script>

Multiple Suspense Boundaries

You can have multiple Suspense boundaries for independent loading states:
const fetchPosts = async () => {
  await new Promise(resolve => setTimeout(resolve, 1000))
  return [{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }]
}

const fetchComments = async () => {
  await new Promise(resolve => setTimeout(resolve, 3000))
  return [{ id: 1, text: 'Comment 1' }]
}

const Posts = async () => {
  const posts = await fetchPosts()
  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  )
}

const Comments = async () => {
  const comments = await fetchComments()
  return (
    <div>
      {comments.map(comment => <div key={comment.id}>{comment.text}</div>)}
    </div>
  )
}

const Page = () => {
  return (
    <html>
      <body>
        <h1>Blog</h1>
        
        <section>
          <h2>Posts</h2>
          <Suspense fallback={<div>Loading posts...</div>}>
            <Posts />
          </Suspense>
        </section>
        
        <section>
          <h2>Comments</h2>
          <Suspense fallback={<div>Loading comments...</div>}>
            <Comments />
          </Suspense>
        </section>
      </body>
    </html>
  )
}

Nested Suspense

Suspense boundaries can be nested:
const UserProfile = async ({ id }: { id: string }) => {
  const user = await fetchUser(id)
  
  return (
    <div>
      <h2>{user.name}</h2>
      <Suspense fallback={<div>Loading posts...</div>}>
        <UserPosts userId={id} />
      </Suspense>
    </div>
  )
}

const Page = ({ userId }: { userId: string }) => {
  return (
    <Suspense fallback={<div>Loading profile...</div>}>
      <UserProfile id={userId} />
    </Suspense>
  )
}

StreamingContext

Use StreamingContext to configure streaming behavior, such as adding a nonce for CSP:
import { Suspense, StreamingContext } from 'hono/jsx/streaming'

const Page = ({ userId, nonce }: { userId: string; nonce: string }) => {
  return (
    <html>
      <head>
        <meta httpEquiv="Content-Security-Policy" 
              content={`script-src 'nonce-${nonce}'`} />
      </head>
      <body>
        <StreamingContext.Provider value={{ scriptNonce: nonce }}>
          <h1>Secure Page</h1>
          <Suspense fallback={<div>Loading...</div>}>
            <UserProfile id={userId} />
          </Suspense>
        </StreamingContext.Provider>
      </body>
    </html>
  )
}

app.get('/secure/:id', (c) => {
  const userId = c.req.param('id')
  const nonce = generateNonce() // Your nonce generation logic
  
  const stream = renderToReadableStream(<Page userId={userId} nonce={nonce} />)
  
  return c.body(stream, {
    headers: {
      'Content-Type': 'text/html; charset=UTF-8',
    },
  })
})
Source: src/jsx/streaming.ts:18-32

Error Handling

Handle errors in streaming with a custom error handler:
const ProblematicComponent = async () => {
  throw new Error('Failed to load data')
}

app.get('/error', (c) => {
  const stream = renderToReadableStream(
    <html>
      <body>
        <Suspense fallback={<div>Loading...</div>}>
          <ProblematicComponent />
        </Suspense>
      </body>
    </html>,
    (error) => {
      console.error('Stream error:', error)
      return '<div>An error occurred</div>'
    }
  )
  
  return c.body(stream, {
    headers: {
      'Content-Type': 'text/html; charset=UTF-8',
    },
  })
})
The error handler receives the error and can return HTML to display:
const errorHandler = (error: unknown): string | void => {
  console.error('Error:', error)
  return '<div class="error">Something went wrong</div>'
}

const stream = renderToReadableStream(<App />, errorHandler)
Source: src/jsx/streaming.ts:144

Async Components

Any component can be async when used with Suspense:
const fetchData = async (url: string) => {
  const response = await fetch(url)
  return response.json()
}

const DataDisplay = async ({ url }: { url: string }) => {
  const data = await fetchData(url)
  
  return (
    <div>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  )
}

const Page = () => {
  return (
    <div>
      <h1>Data</h1>
      <Suspense fallback={<div>Loading data...</div>}>
        <DataDisplay url="https://api.example.com/data" />
      </Suspense>
    </div>
  )
}

Promise Resolution

Suspense automatically handles promises in the component tree:
const fetchUser = async (id: string) => {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

const UserCard = async ({ id }: { id: string }) => {
  const user = await fetchUser(id)
  
  return (
    <div className="card">
      <h3>{user.name}</h3>
      <p>{user.bio}</p>
    </div>
  )
}

const Dashboard = ({ userIds }: { userIds: string[] }) => {
  return (
    <div className="dashboard">
      {userIds.map(id => (
        <Suspense key={id} fallback={<div>Loading user {id}...</div>}>
          <UserCard id={id} />
        </Suspense>
      ))}
    </div>
  )
}

Streaming with the Stream Helper

Combine with Hono’s streaming helper for more control:
import { Hono } from 'hono'
import { stream } from 'hono/streaming'
import { Suspense, renderToReadableStream } from 'hono/jsx/streaming'

const app = new Hono()

app.get('/advanced-stream', (c) => {
  return stream(c, async (stream) => {
    // Send initial HTML
    await stream.write('<!DOCTYPE html><html><body>')
    
    // Stream JSX content
    const jsx = (
      <div>
        <h1>Streaming Content</h1>
        <Suspense fallback={<div>Loading...</div>}>
          <AsyncContent />
        </Suspense>
      </div>
    )
    
    const reader = renderToReadableStream(jsx).getReader()
    
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      await stream.write(value)
    }
    
    // Close HTML
    await stream.write('</body></html>')
  })
})

Performance Considerations

Wrap components that fetch data or perform expensive operations in Suspense boundaries.
<Suspense fallback={<Skeleton />}>
  <ExpensiveComponent />
</Suspense>
Provide informative fallbacks that match the content shape.
const Skeleton = () => (
  <div className="skeleton">
    <div className="skeleton-header" />
    <div className="skeleton-body" />
  </div>
)
Structure your page so critical content loads first.
<div>
  <Header />  {/* Static, sends immediately */}
  <Suspense fallback={<div>Loading...</div>}>
    <MainContent />  {/* Async, streams when ready */}
  </Suspense>
</div>
Streaming is most beneficial on slower networks. On fast connections, the difference may be negligible.

Real-World Example: Product Page

import { Suspense, renderToReadableStream } from 'hono/jsx/streaming'

const fetchProduct = async (id: string) => {
  const res = await fetch(`/api/products/${id}`)
  return res.json()
}

const fetchReviews = async (productId: string) => {
  // Simulate slow API
  await new Promise(resolve => setTimeout(resolve, 3000))
  const res = await fetch(`/api/products/${productId}/reviews`)
  return res.json()
}

const fetchRecommendations = async (productId: string) => {
  await new Promise(resolve => setTimeout(resolve, 2000))
  const res = await fetch(`/api/products/${productId}/recommendations`)
  return res.json()
}

const ProductDetails = async ({ id }: { id: string }) => {
  const product = await fetchProduct(id)
  
  return (
    <div className="product-details">
      <h1>{product.name}</h1>
      <img src={product.image} alt={product.name} />
      <p className="price">${product.price}</p>
      <p>{product.description}</p>
      <button>Add to Cart</button>
    </div>
  )
}

const ProductReviews = async ({ productId }: { productId: string }) => {
  const reviews = await fetchReviews(productId)
  
  return (
    <div className="reviews">
      <h2>Customer Reviews</h2>
      {reviews.map((review: any) => (
        <div key={review.id} className="review">
          <div className="rating">{'⭐'.repeat(review.rating)}</div>
          <p>{review.comment}</p>
          <span className="author">- {review.author}</span>
        </div>
      ))}
    </div>
  )
}

const Recommendations = async ({ productId }: { productId: string }) => {
  const products = await fetchRecommendations(productId)
  
  return (
    <div className="recommendations">
      <h2>You May Also Like</h2>
      <div className="product-grid">
        {products.map((product: any) => (
          <div key={product.id} className="product-card">
            <img src={product.image} alt={product.name} />
            <h3>{product.name}</h3>
            <p>${product.price}</p>
          </div>
        ))}
      </div>
    </div>
  )
}

const ProductPage = ({ productId }: { productId: string }) => {
  return (
    <html>
      <head>
        <title>Product Page</title>
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body>
        <div className="container">
          {/* Product details load first - most important */}
          <Suspense fallback={
            <div className="skeleton-product">
              <div className="skeleton-image" />
              <div className="skeleton-text" />
            </div>
          }>
            <ProductDetails id={productId} />
          </Suspense>
          
          {/* Reviews and recommendations stream in parallel */}
          <div className="secondary-content">
            <Suspense fallback={<div>Loading reviews...</div>}>
              <ProductReviews productId={productId} />
            </Suspense>
            
            <Suspense fallback={<div>Loading recommendations...</div>}>
              <Recommendations productId={productId} />
            </Suspense>
          </div>
        </div>
      </body>
    </html>
  )
}

app.get('/product/:id', (c) => {
  const productId = c.req.param('id')
  const stream = renderToReadableStream(<ProductPage productId={productId} />)
  
  return c.body(stream, {
    headers: {
      'Content-Type': 'text/html; charset=UTF-8',
      'Transfer-Encoding': 'chunked',
    },
  })
})

Browser Compatibility

Streaming works in all modern browsers. The generated JavaScript for replacing fallbacks is minimal and works without any external dependencies.

Limitations

  • Streaming is experimental and the API may change
  • Error boundaries inside Suspense may have unexpected behavior
  • Some CDN/proxy configurations may buffer responses, negating streaming benefits
  • Client-side JavaScript is required for content replacement

Next Steps

Server Rendering

Learn about standard server-side rendering

DOM Rendering

Build interactive UIs with client-side rendering

JSX Overview

Back to JSX overview

Streaming Helper

Learn about the streaming helper

Build docs developers (and LLMs) love