Skip to content

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:

tsx
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

renderToStringrenderToStream
ReturnsstringReadableStream
When to useSmall pages, caching, email HTMLLarge pages, data-heavy pages
TTFBAfter entire page rendersAs soon as first chunk is ready
SimplicitySimpler (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:

tsx
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:

tsx
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:

ts
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:

ts
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:

ts
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:

ts
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-Type from the file extension
  • Sets Content-Length from 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:

ts
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:

ts
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-2000

The server responds with:

HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-2000/10000
Content-Length: 1001

And only the requested bytes are sent.

How It Works

  1. The client sends a request with Range: bytes=start-end.
  2. createFileResponse reads the Range header from the request.
  3. It opens the file and seeks to the start position.
  4. It sends only the requested bytes with a 206 Partial Content status.
  5. It includes Content-Range and Accept-Ranges: bytes headers.

Supporting Video Playback

HTML5 <video> and <audio> elements use range requests automatically:

html
<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:

ts
import { compression } from 'remix/compression-middleware'

let router = createRouter({
  middleware: [compression()],
})

How Compression and Streaming Work Together

  1. The handler returns a streaming Response.
  2. The compression middleware checks the client's Accept-Encoding header.
  3. If the client supports gzip or brotli, the middleware wraps the stream in a compression transform.
  4. 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

FormatHeaderCompression RatioSpeed
BrotlibrBestSlower
GzipgzipGoodFast
DeflatedeflateGoodFast

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:

tsx
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:

tsx
<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):

ts
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:

ts
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:

tsx
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' },
  })
}

Released under the MIT License.