Streaming
Streaming means sending data in chunks as it becomes available, rather than waiting for the entire response to be ready before sending anything. This is one of the most impactful performance techniques for web applications.
What is Streaming?
In a traditional (non-streaming) response, the server does all its work --- querying databases, rendering HTML, compiling data --- and then sends the complete response to the browser all at once. The browser waits, showing nothing, until the entire response arrives.
With streaming, the server sends parts of the response as they become ready. The browser can start processing and displaying content immediately, even while the server is still working on the rest.
Non-streaming:
Server: [====loading====][===rendering===] --> [full response]
Browser: [........waiting........] [render]
Streaming:
Server: [==loading==][sending chunks...]
Browser: [..wait..][render header][render content][render footer]The total time to complete the page might be the same, but the user sees content much sooner. This difference in perceived performance can be dramatic.
Why Streaming Matters
Faster Time-to-First-Byte (TTFB)
TTFB is the time between the browser sending a request and receiving the first byte of the response. With streaming, the first byte arrives as soon as the server has any content ready (like the HTML <head> and the beginning of the page layout), rather than after all data has loaded and the entire page has rendered.
Better Perceived Performance
Users perceive a page as faster when they see content appearing progressively. A page that shows a header and navigation in 100ms and loads the main content in 500ms feels faster than a page that shows nothing for 500ms and then appears all at once.
Progressive Rendering
Browsers are designed to render HTML incrementally. As soon as they receive the <head> section, they can start loading CSS and JavaScript. As body content arrives, they render it. Streaming takes advantage of this built-in behavior.
Better Core Web Vitals
Streaming improves several metrics that search engines use to evaluate page quality:
- Largest Contentful Paint (LCP) --- The main content appears sooner.
- First Contentful Paint (FCP) --- Something appears on screen sooner.
- Time to First Byte (TTFB) --- The first byte arrives sooner.
Streaming HTML with renderToStream()
Remix's renderToStream() function renders a component tree to a ReadableStream of HTML:
import { renderToStream } from 'remix/component/server'
handler({ get }) {
let db = get(Database)
let books = await db.findMany(booksTable)
let stream = renderToStream(
<Layout title="Books">
<h1>All Books</h1>
<BookList books={books} />
</Layout>,
)
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})
}The browser receives the <html>, <head>, and the beginning of <body> immediately. As each component in the tree finishes rendering, its HTML is sent to the browser.
When to Use renderToStream vs renderToString
renderToString | renderToStream | |
|---|---|---|
| Returns | string | ReadableStream |
| When to use | Small pages, caching, email HTML | Large pages, data-heavy pages |
| TTFB | After entire page renders | As soon as first chunk is ready |
| Simplicity | Simpler (just a string) | Slightly more complex (stream) |
For most production applications, renderToStream is the better default. Use renderToString when you need the full HTML as a string (e.g., for caching or sending as email).
Streaming with Data Loading
The real power of streaming appears when parts of the page depend on data that takes time to load. With streaming, the page shell (header, navigation, layout) can be sent immediately while data-dependent sections load:
function BookPage() {
return ({ book }: { book: Book }) => (
<Layout title={book.title}>
{/* This renders immediately */}
<header>
<h1>{book.title}</h1>
<p>by {book.author}</p>
</header>
{/* This section might depend on a slower query */}
<section>
<h2>Details</h2>
<p>{book.description}</p>
</section>
</Layout>
)
}The DOCTYPE and Streaming
When streaming HTML, the <!DOCTYPE html> declaration and <html> tag are sent first. This tells the browser to start parsing in standards mode immediately:
function Document() {
return ({ children }: { children?: RemixNode }) => (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<title>My App</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
{children}
</body>
</html>
)
}The <head> is sent early, so the browser starts loading CSS and fonts before the body content arrives.
ReadableStream Responses
For non-HTML streaming (JSON data, Server-Sent Events, real-time feeds), create a ReadableStream directly:
handler() {
let stream = new ReadableStream({
start(controller) {
let encoder = new TextEncoder()
controller.enqueue(encoder.encode('First chunk\n'))
setTimeout(() => {
controller.enqueue(encoder.encode('Second chunk\n'))
controller.close()
}, 1000)
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' },
})
}Server-Sent Events (SSE)
SSE is a standard for sending real-time updates from server to client. It uses a long-lived streaming response:
handler() {
let stream = new ReadableStream({
start(controller) {
let encoder = new TextEncoder()
let count = 0
let interval = setInterval(() => {
count++
let event = `data: ${JSON.stringify({ count, time: Date.now() })}\n\n`
controller.enqueue(encoder.encode(event))
if (count >= 10) {
clearInterval(interval)
controller.close()
}
}, 1000)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}The client connects with EventSource:
let source = new EventSource('/api/events')
source.onmessage = (event) => {
let data = JSON.parse(event.data)
console.log(data)
}Streaming File Downloads
The createFileResponse helper streams files from disk without loading the entire file into memory:
import { createFileResponse } from 'remix/response/file'
handler({ request, params }) {
let filePath = `/var/data/exports/${params.filename}`
return createFileResponse(filePath, request)
}createFileResponse automatically:
- Determines the
Content-Typefrom the file extension - Sets
Content-Lengthfrom the file size - Streams the file content (does not load the entire file into memory)
- Supports range requests (see below)
- Supports conditional requests (
If-None-Match,If-Modified-Since)
Manual File Streaming
For more control, you can stream files manually:
import * as fs from 'node:fs'
import { Readable } from 'node:stream'
handler({ params }) {
let filePath = `/var/data/exports/${params.filename}`
let stat = fs.statSync(filePath)
let nodeStream = fs.createReadStream(filePath)
// Convert Node.js readable stream to web ReadableStream
let webStream = Readable.toWeb(nodeStream)
return new Response(webStream, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(stat.size),
'Content-Disposition': `attachment; filename="${params.filename}"`,
},
})
}The Content-Disposition: attachment header tells the browser to download the file instead of displaying it.
Range Requests for Media Files
Range requests allow the client to request only a portion of a file. This is essential for:
- Video and audio --- The browser requests chunks as needed for playback and seeking.
- Large file downloads --- Resume interrupted downloads.
- PDF viewers --- Load pages on demand.
createFileResponse handles range requests automatically when the client sends a Range header:
import { createFileResponse } from 'remix/response/file'
// This automatically supports range requests
handler({ request, params }) {
return createFileResponse(`./media/${params.filename}`, request)
}When the client sends:
Range: bytes=1000-2000The server responds with:
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-2000/10000
Content-Length: 1001And only the requested bytes are sent.
How It Works
- The client sends a request with
Range: bytes=start-end. createFileResponsereads theRangeheader from the request.- It opens the file and seeks to the start position.
- It sends only the requested bytes with a
206 Partial Contentstatus. - It includes
Content-RangeandAccept-Ranges: bytesheaders.
Supporting Video Playback
HTML5 <video> and <audio> elements use range requests automatically:
<video src="/media/intro.mp4" controls></video>The browser sends range requests as the user plays and seeks through the video. As long as your route handler uses createFileResponse, everything works automatically.
Compression with Streaming
The compression() middleware works with streaming responses. It compresses each chunk as it is sent:
import { compression } from 'remix/compression-middleware'
let router = createRouter({
middleware: [compression()],
})How Compression and Streaming Work Together
- The handler returns a streaming
Response. - The compression middleware checks the client's
Accept-Encodingheader. - If the client supports gzip or brotli, the middleware wraps the stream in a compression transform.
- Each chunk is compressed and sent as it arrives.
The browser decompresses chunks as they arrive, so the user still sees progressive rendering.
When Compression Is Skipped
The compression middleware automatically skips compression for:
- Responses that are already compressed (e.g., JPEG, PNG, GZIP files)
- Very small responses (compression overhead would exceed the savings)
- Responses without a body (204, 304)
- Responses where the client does not accept compression
Compression Formats
| Format | Header | Compression Ratio | Speed |
|---|---|---|---|
| Brotli | br | Best | Slower |
| Gzip | gzip | Good | Fast |
| Deflate | deflate | Good | Fast |
The middleware prefers brotli when the client supports it, as it provides the best compression ratio for text content (HTML, CSS, JavaScript).
Streaming Best Practices
Send the <head> Early
Structure your components so the <head> section (with CSS and font links) is at the top of the response. This lets the browser start loading critical resources while the body streams:
function Document() {
return ({ title, children }: { title: string; children?: RemixNode }) => (
<html>
<head>
{/* These load immediately */}
<link rel="stylesheet" href="/app.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<title>{title}</title>
</head>
<body>
{/* This streams progressively */}
{children}
</body>
</html>
)
}Use the Frame Component for Slow Sections
If one part of the page depends on a slow data source, isolate it in a Frame so the rest of the page is not blocked:
<Layout>
<h1>Dashboard</h1>
{/* These render immediately */}
<QuickStats stats={quickStats} />
{/* This loads independently */}
<Frame src="/api/dashboard/charts">
<div class="skeleton">Loading charts...</div>
</Frame>
</Layout>Set Appropriate Headers
For streaming responses, avoid setting Content-Length (you may not know the final size). Use chunked transfer encoding instead (which is the default when you return a ReadableStream):
return new Response(stream, {
headers: {
'Content-Type': 'text/html; charset=UTF-8',
// Do NOT set Content-Length for streaming
// Transfer-Encoding: chunked is used automatically
},
})Handle Errors in Streams
If an error occurs while streaming, you cannot change the status code (it was already sent). Instead, include an error indicator in the stream:
let stream = new ReadableStream({
async start(controller) {
let encoder = new TextEncoder()
try {
controller.enqueue(encoder.encode('<div>'))
// ... stream content ...
controller.enqueue(encoder.encode('</div>'))
} catch (error) {
controller.enqueue(
encoder.encode('<div class="error">An error occurred while loading this section.</div>'),
)
}
controller.close()
},
})Complete Streaming Example
Here is a full example of a streaming page with a fast shell and slow data:
import { renderToStream } from 'remix/component/server'
import { Database } from 'remix/data-table'
handler({ get }) {
let db = get(Database)
// Start the data query (but don't await it yet for the shell)
let booksPromise = db.findMany(books, {
orderBy: ['published_year', 'desc'],
limit: 50,
})
// The stream starts sending the shell immediately
let stream = renderToStream(
<StreamingBookPage booksPromise={booksPromise} />,
)
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})
}Related
- Components & JSX ---
renderToStreamandrenderToStringin detail. - Request & Response --- Creating
Responseobjects with streams. - Middleware --- The compression middleware.