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:
The fallback is rendered immediately and sent to the client
The async component starts loading in the background
Once loaded, the actual content is sent to the client
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>' )
})
})
Use Suspense for slow operations
Wrap components that fetch data or perform expensive operations in Suspense boundaries. < Suspense fallback = { < Skeleton /> } >
< ExpensiveComponent />
</ Suspense >
Show meaningful loading states
Provide informative fallbacks that match the content shape. const Skeleton = () => (
< div className = "skeleton" >
< div className = "skeleton-header" />
< div className = "skeleton-body" />
</ div >
)
Stream above-the-fold content first
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 >
Consider network conditions
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