How to Implement HTTP Streaming and Server-Sent Events in Bun

Bun's native Web Streams integration allows you to implement HTTP streaming and Server-Sent Events by returning a Response with a ReadableStream body, using standard controllers for backpressure and a cancel hook for cleanup.

The oven-sh/bun runtime treats streaming as a first-class citizen. Built around the Web Streams API, Bun.serve lets handlers return a Response whose body is any ReadableStream, enabling everything from chunked text delivery to high-performance binary streaming without external dependencies.

Understanding Bun's Web Streams Architecture

Bun's HTTP server implementation in src/js/node/http2.ts and the Server class definitions in src/bun.js/api/server.classes.ts expose the Web Streams API directly to user code. When a handler returns a Response wrapping a ReadableStream, Bun maps that stream to the underlying HTTP response sink, handling backpressure, chunked transfer encoding, and connection lifecycle automatically.

The stream controller exposes enqueue() for pushing data and close() for terminating the stream. For production use, you must implement the cancel() callback to release resources when clients disconnect prematurely.

Implementing HTTP Streaming in Bun

Basic ReadableStream Implementation

The standard pattern for HTTP streaming involves creating a ReadableStream with start and pull callbacks, then wrapping it in a Response. This example from the Bun test suite (test/js/bun/http/serve.test.ts, lines 525-560) demonstrates the fundamental approach:

using server = Bun.serve({
  port: 0,
  async fetch(_req) {
    const data = "Hello, streaming world!\n";
    return new Response(
      new ReadableStream({
        start(controller) {
          controller.enqueue(new TextEncoder().encode(data));
          controller.close();
        },
      })
    );
  },
});

console.log(`Server listening at ${server.url}`);

The start callback initializes the stream, controller.enqueue() accepts Uint8Array, string, or any ArrayBufferView, and controller.close() signals completion. Bun automatically selects chunked encoding when the content length is unknown.

Handling Client Disconnections

Every streaming request must handle premature closure. Implement the cancel hook on the ReadableStream to release timers, file handles, or database connections. You can also monitor req.signal for abort events, as shown in serve.test.ts (lines 2860-2875):

using server = Bun.serve({
  port: 0,
  async fetch(req) {
    const abortPromise = new Promise<void>((resolve) => {
      req.signal.addEventListener("abort", () => resolve());
    });

    return new Response(
      new ReadableStream({
        async start(controller) {
          for (let i = 0; i < 1_000; i++) {
            if (controller.desiredSize === 0) await Bun.sleep(10);
            controller.enqueue(`chunk ${i}\n`);
            if (await Promise.race([abortPromise, Promise.resolve()])) break;
          }
          controller.close();
        },
      })
    );
  },
});

The cancel method on the stream controller and the req.signal abort event both fire when the client disconnects, ensuring resources are not leaked.

Building Server-Sent Events (SSE) Endpoints

SSE Protocol Requirements

Server-Sent Events require specific HTTP headers and payload formatting. According to the Bun source implementation in serve.test.ts (lines 2001-2026), an SSE response must include:

  • Content-Type: text/event-stream – identifies the response as an event stream
  • Cache-Control: no-cache – prevents intermediaries from buffering events
  • Payload format – messages prefixed with data: and terminated by two newline characters (\n\n)

Complete SSE Implementation

The following implementation demonstrates a robust SSE endpoint that sends periodic messages and cleans up resources when the client disconnects:

using server = Bun.serve({
  port: 0,
  async fetch(_req) {
    let controller: ReadableStreamDefaultController | undefined;
    let interval: NodeJS.Timer | null = null;
    const payload = new TextEncoder().encode("data: hello\n\n");
    const CHUNKS = 5;

    interval = setInterval(() => {
      controller?.enqueue(payload);
      if (--CHUNKS === 0) {
        clearInterval(interval!);
        interval = null;
        controller?.close();
      }
    }, 1_000);

    return new Response(
      new ReadableStream({
        start(ctrl) { controller = ctrl; },
        cancel() { if (interval) clearInterval(interval); },
      }),
      {
        headers: {
          "Content-Type": "text/event-stream",
          "Cache-Control": "no-cache",
        },
      },
    );
  },
});

The cancel hook is essential here—it clears the interval timer when the client closes the connection, preventing memory leaks and unnecessary CPU usage.

Cleanup and Resource Management

SSE connections are long-lived, making cleanup critical. Always implement the cancel callback on the ReadableStream to:

  • Clear intervals or timeouts
  • Close file descriptors or database connections
  • Release memory buffers

The Bun runtime invokes cancel automatically when the underlying HTTP connection closes, whether through client disconnect, network error, or timeout.

Optimizing with Direct Mode Streaming

For high-throughput scenarios involving large binary data or file transfers, Bun offers a direct mode that bypasses JavaScript-level buffering. In test/js/bun/http/serve-direct-readable-stream.test.ts, the implementation shows how to use type: "direct":

using server = Bun.serve({
  port: 0,
  async fetch(_req) {
    return new Response(
      new ReadableStream({
        type: "direct",
        async pull(controller) {
          await controller.write("chunk");
          await Bun.sleep(0);
          controller.close();
        },
      })
    );
  },
});

In direct mode, the controller exposes write, flush, and close methods that operate on the underlying HTTPResponseSink. This provides zero-copy streaming ideal for:

  • Serving large video files
  • Proxying binary data from upstream services
  • Real-time audio streaming

Summary

  • Bun.serve natively supports HTTP streaming by accepting a ReadableStream as a Response body, eliminating the need for external libraries.
  • Standard streaming uses controller.enqueue() to push chunks and controller.close() to end the stream, with automatic backpressure handling.
  • Server-Sent Events require Content-Type: text/event-stream headers and data: …\n\n payload formatting; always implement the cancel hook to prevent timer leaks.
  • Direct mode (type: "direct") offers zero-copy binary streaming via controller.write() for high-performance file and data delivery.
  • Cleanup is mandatory for long-lived connections—use cancel callbacks and req.signal to release resources when clients disconnect.

Frequently Asked Questions

What is the difference between HTTP streaming and WebSockets in Bun?

HTTP streaming and Server-Sent Events use standard HTTP connections with a persistent response body, making them firewall-friendly and automatically handling reconnection via the browser's EventSource API. WebSockets require a protocol upgrade to ws:// or wss:// and provide bidirectional communication. In Bun, both are supported natively, but SSE is often simpler for unidirectional server-to-client updates like live logs or notifications.

How do I handle client disconnections in Bun SSE streams?

Always implement the cancel callback on your ReadableStream and monitor req.signal for abort events. When a client closes the connection, Bun invokes cancel, allowing you to clear intervals, close file descriptors, or release database connections. Without this cleanup, long-lived SSE connections will leak memory and consume CPU cycles indefinitely.

When should I use direct mode streaming versus standard ReadableStream?

Use direct mode (type: "direct") when serving large binary files, proxying high-throughput data, or streaming video where zero-copy performance is critical. Direct mode writes directly to the socket via controller.write() and controller.flush(). Use the standard ReadableStream for text-based SSE, JSON streaming, or when you need automatic backpressure handling through controller.enqueue().

Does Bun support backpressure in HTTP streaming?

Yes, Bun automatically handles backpressure when using standard ReadableStream controllers. The controller.desiredSize property indicates when the internal queue is full, allowing you to pause data generation until the client catches up. In direct mode, you must manually manage flow control using controller.flush() to ensure data is sent immediately, though the underlying HTTP/2 implementation in src/js/node/http2.ts still manages TCP backpressure at the transport level.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →