Bytewells by ThoughtPivot
Flight is ThoughtPivot's open-source web framework. Since 3.0.0 it ships flight-bun, a server that runs on Bun.serve and mounts existing Koa backends (export default router.routes()) through a Koa-compat layer. On Oct 18 we pushed 3.0.1 to main. It fixes one thing that matters a lot if you stream: Koa server-sent events (SSE) on flight-bun were buffered until res.end(). The client connected, the server wrote events, and nothing arrived until the stream closed.
The fix touched four separate parts of the stack. Each one was a reasonable choice for request-response traffic and a bug for a stream. This post walks through them, because the same four assumptions are probably sitting in your middleware too.
Commit: https://github.com/thoughtpivot/flight/commit/7db959993b20593e192fbeb599d164751c8ecaf1
The route we wanted to work
This is the usual way to stream from Koa. Nothing Bun-specific:
router.get('/events', (ctx) => {
ctx.respond = false // tell Koa we own the response
ctx.res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
})
const timer = setInterval(() => {
ctx.res.write(`data: ${Date.now()}\n\n`)
}, 1000)
ctx.res.on('close', () => clearInterval(timer))
})
On Node with real Koa, this streams. On flight-bun 3.0.0, it did not.
1. The compat layer waited for end()
flight-bun has no Node ServerResponse. The Koa-compat layer hands the route a mock response object, collects what the route writes, and builds a Response for Bun.serve. For a normal handler that is fine: collect the body, then return it.
For a stream, "collect, then return" means "return never." In 3.0.1 the compat layer returns a ReadableStream response as soon as the route starts a body: on writeHead, write, flushHeaders, or when ctx.respond = false is set. Later writes are pushed into that stream. A stream that never calls end() also no longer hangs with an empty body.
Lesson: if your adapter turns a push-style API into a value, decide the response at the first byte, not at the last.
2. Matching was read too early, so live routes fell through to the SPA 404
Flight serves a single-page app by default and answers unmatched paths with the SPA 404. To decide "matched or not", the compat layer read ctx.matched after the middleware chain ran.
Two things broke that. A route that finished the response inside its handler was dropped and replaced by the 404. And with several routers mounted, later routers append to ctx.matched with push, so reading it at the wrong moment missed a match from a router mounted after another one. In 3.0.1 the match is read when the stream opens or when the response finishes.
Lesson: "did anything handle this request?" is a question about time. With streaming, the answer can arrive after your fallback logic has already run.
3. Client disconnect never reached the route
In the example above, cleanup lives in res.on('close'). On Node that fires when the client goes away. On the mock response it never fired, so heartbeats and subscribers kept running for clients that were gone.
3.0.1 wires the request's abort signal to a close event on the mock response. Routes written the normal Koa way now clean up.
Lesson: every layer between the socket and your handler needs to forward cancellation. One layer that swallows it is enough to leak.
4. Gzip and the response cache waited on the stream
Compression middleware buffers so it can compress efficiently. A response cache needs the full body to store it. Both are correct for a JSON response and both stall an event stream.
In 3.0.1 gzip skips text/event-stream and any body that is still streaming, and the response cache skips those bodies too.
Lesson: check content type early and route streams around anything that needs the whole body.
The new setting: FLIGHT_IDLE_TIMEOUT
Bun's server closes idle connections after 10 seconds by default. A quiet SSE connection looks idle. 3.0.1 adds FLIGHT_IDLE_TIMEOUT (seconds):
- default
10(Bun's own default) - an explicit
0disables the server idle timeout - positive fractions round up to at least
1 - negative or non-numeric values fall back to
10 - values above
255are clamped to Bun's maximum
Server-sent event responses disable the idle timeout for that request, so you do not have to turn it off globally to keep streams open.
Two things that do not carry over from the Node server
The 3.0.1 docs also spell out two differences that are easy to miss when moving a Flight app from Node to Bun:
-
No cluster.
flight-bunis one process. It does not readFLIGHT_MAX_WORKERSand does not use Node'sclustermodule. Scale with replicas. (Our benchmark note: on 4 cores, a 4-worker Node cluster served 80.7k req/s against 53.8k forflight-bun. Method and raw data are inbench/BENCHMARKS.md.) -
Rate-limit storage is per process. The Bun limiter keeps counters in an in-memory
Mapand does not use Redis, even whenFLIGHT_REDIS_URLis set. Replicas do not share a window, so a limit of 100 across four processes allows 400 requests in the window.
Checklist for your own stack
If you are adding SSE to a framework or adapter you did not write:
- Does the adapter return a streaming body at the first write?
- Is "was this request handled?" decided after the stream opens, not before?
- Does client disconnect reach
res.on('close')(or your equivalent)? - Do compression and caching skip
text/event-stream? - What is your server's idle timeout, and is it disabled for streams only?
Flight is at https://github.com/thoughtpivot/flight. 3.0.1 is on main; there is no GitHub release tag for it yet. Issues and PRs are welcome.
We build Flight alongside Bytewells by ThoughtPivot, a store for monthly rentals and short passes on Apify-compatible Actors, opening Nov 2. Developer waitlist: https://bytewells.com/developer-waitlist


