Pipe Node Readable Streams Through Koa Without Buffering
Streaming Responses
Set ctx.body to a Node.js Readable stream to deliver large files and generated content efficiently, handle backpressure automatically, and trigger browser downloads with ctx.attachment.
What you'll learn
- Assign a Readable stream to ctx.body and let Koa pipe it to the client
- Set Content-Disposition with ctx.attachment to prompt file downloads
- Understand how Koa and Node handle backpressure on streamed responses
When a response is large — a CSV export, a video file, or generated content —
buffering it entirely in memory before sending wastes RAM and delays the
first byte. Koa supports streaming natively: assign any
Node.js Readable stream
to ctx.body and Koa pipes it to the HTTP response socket.
Streaming a File
import Router from '@koa/router';
import fs from 'node:fs';
import path from 'node:path';
const router = new Router();
router.get('/reports/:name', async (ctx) => {
const filePath = path.join('data', ctx.params.name);
// Koa will pipe this stream to the client and close it when done
ctx.body = fs.createReadStream(filePath);
ctx.type = 'text/csv';
}); Koa detects that ctx.body is a stream and calls stream.pipe(res) under the
hood. If the stream emits an error, Koa destroys the response socket and emits
an error event on the app.
Triggering a File Download
ctx.attachment(filename) sets the Content-Disposition: attachment header,
which tells the browser to save the file rather than display it.
router.get('/export/users.csv', async (ctx) => {
const { rows } = await pool.query('SELECT id, name, email FROM users');
// Build a CSV string — for large datasets use a streaming CSV library
const csv = ['id,name,email', ...rows.map((r) => `${r.id},${r.name},${r.email}`)].join('\n');
ctx.attachment('users.csv');
ctx.type = 'text/csv';
ctx.body = csv; // string body, not a stream — fine for small results
}); For truly large exports, pipe a transform stream that generates CSV rows on the
fly instead of accumulating csv in memory.
Streaming Generated Content
Combine PassThrough with an async generator to emit data incrementally.
import { PassThrough } from 'node:stream';
router.get('/stream/numbers', async (ctx) => {
const stream = new PassThrough();
ctx.type = 'text/plain';
ctx.body = stream;
// Write data asynchronously without blocking the event loop
(async () => {
for (let i = 1; i <= 100; i++) {
stream.write(`${i}\n`);
await new Promise((r) => setTimeout(r, 20)); // simulate slow data
}
stream.end();
})();
}); Backpressure
When the client reads slower than the server writes, Node’s stream machinery
applies backpressure automatically. Respecting write() return values and
drain events prevents the internal buffer from growing without bound.
| Situation | Recommendation |
|---|---|
| Reading from disk | fs.createReadStream — handles backpressure natively |
| Database cursor | Use the driver’s streaming query API, not fetchAll |
| Generated data | Check stream.write() return; pause source on false |
| Proxy another HTTP response | Pipe axios/got response stream directly to ctx.body |
Up Next
Learn how to push real-time events to the browser with Server-Sent Events, which build directly on Koa’s streaming support.
Server-Sent Events →