Streaming Responses

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.

4 min read Level 3/5 #koa#data#streaming
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.

SituationRecommendation
Reading from diskfs.createReadStream — handles backpressure natively
Database cursorUse the driver’s streaming query API, not fetchAll
Generated dataCheck stream.write() return; pause source on false
Proxy another HTTP responsePipe 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 →