Streaming files with StreamableFile in NestJS
Large file downloads are a fact of life in production applications. Whether you're serving PDFs, videos, or database exports, loading an entire file into memory before sending it to the client is a performance disaster waiting to happen. Streaming files with StreamableFile is NestJS's answer to this problem: it lets you send files directly to clients without consuming heap memory, regardless of file size. In this post, I'll show you how to implement efficient file streaming in your NestJS applications using both local disk and cloud storage.
Understanding StreamableFile and why it matters
NestJS provides the StreamableFile class to wrap Node.js readable streams and send them directly as HTTP response bodies. When you return a StreamableFile from a controller method, NestJS pipes the stream data to the client in chunks, keeping memory usage constant regardless of the file size. This is crucial for production systems where you might be serving gigabyte-sized files or handling concurrent downloads.
I've dealt with this in production ride-hailing platforms where drivers needed to download trip reports and expense receipts. Without streaming, a single large report request would spike memory usage and potentially crash the service. With StreamableFile, we could handle dozens of concurrent downloads on modest hardware.
The key insight is that streams transfer data in small, manageable chunks. Your application reads a chunk from disk or S3, writes it to the response, and then forgets about it. The next chunk is processed independently. Memory usage stays flat.
Return new StreamableFile(stream)
The basic pattern is straightforward: create a readable stream, wrap it in StreamableFile, and return it from your controller. NestJS handles the rest.
import { Controller, Get, Res, StreamableFile } from '@nestjs/common';
import { createReadStream } from 'fs';
import { Response } from 'express';
@Controller('files')
export class FilesController {
@Get('download/:filename')
downloadFile(
@Param('filename') filename: string,
@Res({ passthrough: true }) res: Response,
): StreamableFile {
const stream = createReadStream(`./uploads/${filename}`);
return new StreamableFile(stream);
}
}Notice the @Res({ passthrough: true }) decorator. This tells NestJS you're handling response headers manually but still want it to manage the actual response transmission. Without passthrough: true, NestJS would send the response immediately and you'd get errors trying to set headers.
The StreamableFile constructor accepts any Node.js Readable stream. You can pass streams from fs.createReadStream(), buffers wrapped in Readable.from(), or streams from third-party libraries. The beauty is that your controller code doesn't care about the stream's origin—just its interface.
Set Content-Type and Content-Disposition
Returning a stream is only half the battle. You need to tell the client what type of file it's receiving and how the browser should handle it. This is where HTTP headers come in.
The Content-Type header tells the browser the file's MIME type. The Content-Disposition header instructs the browser to download the file with a specific name rather than display it inline. Here's the pattern:
@Get('download/:filename')
downloadFile(
@Param('filename') filename: string,
@Res({ passthrough: true }) res: Response,
): StreamableFile {
const stream = createReadStream(`./uploads/${filename}`);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="report.pdf"',
});
return new StreamableFile(stream);
}For PDFs, use application/pdf. For Excel files, use application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. For plain text, use text/plain. If you're unsure about MIME types, the mime npm package can detect them from file extensions:
import mime from 'mime';
@Get('download/:filename')
downloadFile(
@Param('filename') filename: string,
@Res({ passthrough: true }) res: Response,
): StreamableFile {
const stream = createReadStream(`./uploads/${filename}`);
const mimeType = mime.lookup(filename) || 'application/octet-stream';
res.set({
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${filename}"`,
});
return new StreamableFile(stream);
}The Content-Disposition header has two modes: attachment forces a download, while inline tells the browser to display the file if possible. For most user-initiated downloads, use attachment.
Stream from S3 or disk directly
In production, files often live in cloud storage like AWS S3 rather than your application server. The good news: streaming from S3 works exactly the same way. S3 SDK methods return readable streams that integrate seamlessly with StreamableFile.
Here's a practical example using the AWS SDK v3:
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { Injectable } from '@nestjs/common';
@Injectable()
export class StorageService {
private s3Client = new S3Client({ region: 'us-east-1' });
async getFileStream(bucket: string, key: string) {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
const response = await this.s3Client.send(command);
return response.Body; // This is a readable stream
}
}
@Controller('files')
export class FilesController {
constructor(private storage: StorageService) {}
@Get('download/:key')
async downloadFromS3(
@Param('key') key: string,
@Res({ passthrough: true }) res: Response,
): Promise {
const stream = await this.storage.getFileStream('my-bucket', key);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${key}"`,
});
return new StreamableFile(stream);
}
}The S3 GetObject response includes a Body property that's already a readable stream. No need to buffer or copy data—just pass it to StreamableFile. This approach scales beautifully because network bandwidth from S3 to your application is typically fast, and memory usage remains constant.
For local disk files, the pattern is identical but simpler:
@Get('download/:filename')
downloadFile(
@Param('filename') filename: string,
@Res({ passthrough: true }) res: Response,
): StreamableFile {
const safePath = path.join('./uploads', path.basename(filename));
const stream = createReadStream(safePath);
res.set({
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
});
return new StreamableFile(stream);
}Note the path.basename() call. Always sanitize file paths to prevent directory traversal attacks. A malicious user could request ../../etc/passwd if you don't validate input.
Works with Express and Fastify
NestJS abstracts away the underlying HTTP framework. Whether you're using Express or Fastify, StreamableFile works identically. This is one of NestJS's superpowers: write once, run on multiple frameworks.
With Express (the default), the response handling is straightforward as you've seen in previous examples. Fastify works the same way:
import { Controller, Get, StreamableFile } from '@nestjs/common';
import { createReadStream } from 'fs';
import { FastifyReply } from 'fastify';
@Controller('files')
export class FilesController {
@Get('download/:filename')
downloadFile(
@Param('filename') filename: string,
@Res({ passthrough: true }) reply: FastifyReply,
): StreamableFile {
const stream = createReadStream(`./uploads/${filename}`);
reply.type('application/pdf');
reply.header('Content-Disposition', 'attachment; filename="report.pdf"');
return new StreamableFile(stream);
}
}The API is slightly different—Fastify uses reply.type() instead of Express's res.set()—but the concept is identical. I've migrated applications from Express to Fastify and streaming endpoints required minimal changes.
One practical advantage of Fastify is its lower memory footprint. If you're streaming many large files concurrently, Fastify might be the better choice. In a travel platform I worked on, we switched to Fastify for invoice generation and saw a 15% reduction in baseline memory usage.
Common mistakes
Over the years, I've seen several patterns that break streaming implementations:
- Forgetting passthrough: true. Without it, NestJS sends the response before your headers are set, causing them to be silently ignored or causing errors.
- Not handling stream errors. If the file is deleted between the request and when streaming starts, the stream will emit an error event. Always attach error handlers to streams, especially when pulling from external sources like S3.
- Setting Content-Length without validation. If you set a
Content-Lengthheader, it must be exact. It's safer to omit it and let the browser handle chunked transfer encoding. - Reading entire file first. Wrapping a
BufferinStreamableFiledefeats the purpose. UseReadable.from(buffer)only if you genuinely need to stream a generated buffer. For files, usecreateReadStream(). - Missing path sanitization. Always validate and normalize file paths. Never concatenate user input directly into file paths.
Here's a robust example that handles errors properly:
@Get('download/:filename')
async downloadFile(
@Param('filename') filename: string,
@Res({ passthrough: true }) res: Response,
): Promise {
const safePath = path.join('./uploads', path.basename(filename));
// Verify file exists before streaming
try {
await fs.promises.access(safePath);
} catch (error) {
throw new NotFoundException('File not found');
}
const stream = createReadStream(safePath);
// Attach error handler
stream.on('error', (error) => {
this.logger.error(`Stream error: ${error.message}`);
res.status(500).end();
});
res.set({
'Content-Type': mime.lookup(filename) || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
});
return new StreamableFile(stream);
}Key takeaways
StreamableFilewraps Node.js readable streams and sends them to clients without loading entire files into memory, making it essential for large file downloads.- Always use
@Res({ passthrough: true })when you need to set custom headers before returningStreamableFile. - Set
Content-TypeandContent-Dispositionheaders correctly to ensure browsers handle files as intended. - Streams work seamlessly with both local disk files (via
fs.createReadStream()) and cloud storage (via S3 SDK streams). - NestJS abstracts framework differences, so
StreamableFileworks identically with Express and Fastify with minimal API variations. - Always sanitize file paths and handle stream errors to prevent security issues and runtime failures in production.






Comments
Loading comments…