Streaming Responses

Pipe Large Payloads Without Buffering

Streaming Responses

Return a StreamableFile to stream binary payloads, or grab the response to pipe a Node stream directly.

4 min read Level 3/5 #nestjs#streaming#files
What you'll learn
  • Return a `StreamableFile` from a controller
  • Stream a file from disk
  • Set `Content-Disposition` for downloads

When a response could be tens of megabytes (a PDF, a video, a CSV export) you don’t want to load it all into memory and then send. Nest’s StreamableFile lets you return a stream and Nest pipes it for you.

Returning a StreamableFile

import { createReadStream } from 'node:fs';
import { join } from 'node:path';
import { Controller, Get, StreamableFile } from '@nestjs/common';

@Controller('files')
export class FilesController {
  @Get('report')
  download(): StreamableFile {
    const file = createReadStream(join(process.cwd(), 'report.pdf'));
    return new StreamableFile(file);
  }
}

Nest pipes the read stream to the response. No buffering, no memory spikes, and back-pressure works correctly.

Setting Content-Type and Disposition

StreamableFile accepts options for the common headers:

return new StreamableFile(file, {
  type: 'application/pdf',
  disposition: 'attachment; filename="quarterly.pdf"',
  length: stat.size,
});

type sets Content-Type, disposition triggers a download in the browser, and length (optional but recommended) lets the client show a progress bar.

Streaming Generated Content

You don’t need a file on disk — any Node Readable works. A common trick is generating a CSV row by row:

import { Readable } from 'node:stream';

@Get('users.csv')
export() {
  async function* rows() {
    yield 'id,name\n';
    for await (const u of this.users.cursor()) {
      yield `${u.id},${u.name}\n`;
    }
  }
  return new StreamableFile(Readable.from(rows.call(this)), {
    type: 'text/csv',
    disposition: 'attachment; filename="users.csv"',
  });
}

That stays memory-stable even for millions of rows.

Going Lower-Level With @Res()

If you need fine control (res.write(), custom flush points, SSE-like behavior), drop into the raw response — but you lose Nest’s auto-serialization:

import { Res } from '@nestjs/common';
import type { Response } from 'express';

@Get('live')
live(@Res() res: Response) {
  res.setHeader('Content-Type', 'text/plain');
  res.write('starting…\n');
  setTimeout(() => res.end('done\n'), 1000);
}

Reach for this only when StreamableFile truly can’t express what you need. For server-sent events, the dedicated @Sse() decorator is even better.

Watch Out For

  • Don’t await the whole stream before returning — that defeats the purpose. Hand Nest the readable, let it pipe.
  • Error handling — if the stream errors mid-flight, you can’t change the status code (headers are already sent). Make sure the source is validated before you start piping.
Custom Parameter Decorators →