Skip to content
ExponaBLOG
NESTJS · DAY 059 / 100

Streaming files with StreamableFile for NestJS developers

Send large files without loading them into memory.

Expona Admin8 min read

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.

typescript
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:

typescript
@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:

typescript
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:

typescript
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:

typescript
@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:

typescript
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-Length header, it must be exact. It's safer to omit it and let the browser handle chunked transfer encoding.
  • Reading entire file first. Wrapping a Buffer in StreamableFile defeats the purpose. Use Readable.from(buffer) only if you genuinely need to stream a generated buffer. For files, use createReadStream().
  • 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:

typescript
@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

  • StreamableFile wraps 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 returning StreamableFile.
  • Set Content-Type and Content-Disposition headers 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 StreamableFile works 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles