Async & Streaming Handlers
NestJS controllers are deliberately flexible about what a handler may return. You can return a plain value, a Promise, an RxJS Observable, or a stream — and the framework resolves each shape correctly before serializing the response. Understanding how Nest treats these return types lets you write idiomatic async code, integrate reactive libraries, and stream large payloads without buffering them in memory.
How Nest resolves handler return values
Every route handler runs through Nest’s response-handling layer. When a handler returns a value, Nest inspects it: if it is a Promise, Nest awaits it; if it is an Observable, Nest subscribes and emits the last value the stream produces; otherwise the value is sent as-is. This means async/await and reactive code are first-class citizens — you rarely touch the underlying response object.
import { Controller, Get } from '@nestjs/common';
import { Observable, of } from 'rxjs';
@Controller('values')
export class ValuesController {
@Get('sync')
sync(): string {
return 'returned directly';
}
@Get('async')
async asyncValue(): Promise<string> {
return 'resolved from a promise';
}
@Get('reactive')
reactive(): Observable<string> {
return of('emitted by an observable');
}
}
All three endpoints respond with a 200 and the string body. Nest normalizes the difference for you.
Async/await handlers
The most common pattern is an async handler that awaits a service method. The service typically wraps a repository, HTTP client, or any I/O-bound call. Throwing inside the handler (or a rejected promise) is caught by Nest’s exception layer and mapped to an HTTP response.
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
private readonly users = new Map<string, { id: string; name: string }>([
['1', { id: '1', name: 'Ada Lovelace' }],
]);
async findOne(id: string) {
// simulate async I/O
await new Promise((resolve) => setTimeout(resolve, 10));
return this.users.get(id) ?? null;
}
}
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.users.findOne(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return user;
}
}
Output:
GET /users/1 -> 200 {"id":"1","name":"Ada Lovelace"}
GET /users/9 -> 404 {"statusCode":404,"message":"User 9 not found","error":"Not Found"}
Always
awaityour async work (or return the promise). A forgottenawaitmeans Nest sends an empty response while the work runs detached — and any rejection becomes an unhandled promise rejection instead of a clean HTTP error.
Returning RxJS Observables
If your data source is reactive — an HttpService call, a message-broker stream, or a BehaviorSubject — you can return the Observable directly. Nest subscribes for you and resolves the response with the last emitted value, then completes the subscription. This is ideal for composing operators before the response is built.
import { Controller, Get } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { Observable } from 'rxjs';
import { map, catchError, throwError } from 'rxjs';
import { HttpException } from '@nestjs/common';
@Controller('rates')
export class RatesController {
constructor(private readonly http: HttpService) {}
@Get('usd')
getUsd(): Observable<{ base: string; eur: number }> {
return this.http.get('https://api.example.com/rates/usd').pipe(
map((res) => ({ base: 'USD', eur: res.data.eur })),
catchError(() =>
throwError(() => new HttpException('Upstream unavailable', 502)),
),
);
}
}
Because only the last value is used for the HTTP response, emitting multiple values from an Observable does not produce a multi-part HTTP body — use StreamableFile or raw response streaming for that.
Return-type behavior at a glance
| Return value | Nest behavior | Typical use |
|---|---|---|
| Plain object/string | Serialized and sent immediately | Synchronous data |
Promise<T> | Awaited, then T is serialized | async/await I/O |
Observable<T> | Subscribed; last emitted T is serialized | Reactive pipelines |
StreamableFile | Piped to the response with stream headers | File / large downloads |
Streaming files with StreamableFile
For large or generated payloads, returning a fully materialized buffer wastes memory. StreamableFile wraps a Readable, Buffer, or Uint8Array and lets Nest pipe it directly to the underlying response. You control headers either via StreamableFileOptions or the response object.
import { Controller, Get, StreamableFile, Header } from '@nestjs/common';
import { createReadStream } from 'fs';
import { join } from 'path';
@Controller('files')
export class FilesController {
@Get('report')
@Header('Content-Type', 'application/pdf')
@Header('Content-Disposition', 'attachment; filename="report.pdf"')
getReport(): StreamableFile {
const file = createReadStream(join(process.cwd(), 'storage', 'report.pdf'));
return new StreamableFile(file);
}
@Get('generated')
getGenerated(): StreamableFile {
const file = createReadStream(join(process.cwd(), 'storage', 'data.csv'));
return new StreamableFile(file, {
type: 'text/csv',
disposition: 'attachment; filename="data.csv"',
length: 1024,
});
}
}
The stream is consumed lazily; bytes flow to the client as they are read from disk. If the stream errors after headers are sent, Nest emits an error event you can handle via the optional getErrorHandledStream hook or by listening on the stream directly.
Manual response streaming
When you need full control — Server-Sent Events, chunked text, or a custom protocol — inject the platform response with @Res() and write to it yourself. Doing so opts out of Nest’s automatic serialization, so you become responsible for ending the response.
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
@Controller('stream')
export class StreamController {
@Get('logs')
streamLogs(@Res() res: Response) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
let count = 0;
const timer = setInterval(() => {
res.write(`log line ${++count}\n`);
if (count === 3) {
clearInterval(timer);
res.end();
}
}, 100);
}
}
Output:
log line 1
log line 2
log line 3
Using
@Res()disables interceptors that rely on the return value (e.g.ClassSerializerInterceptor) for that handler. PreferStreamableFileunless you genuinely need to drive the raw response, and pass{ passthrough: true }to@Res()if you still want Nest to handle the final send.
Best Practices
- Prefer returning
PromiseorObservableso Nest’s exception filters, interceptors, and serialization stay in control of the response. - Never forget to
awaitasync work orreturnthe promise — silent empty responses and unhandled rejections both stem from this mistake. - Use
StreamableFilefor downloads instead of reading entire files into aBuffer; it keeps memory flat under load. - Map upstream failures inside RxJS pipelines with
catchErrorso callers receive meaningful HTTP status codes. - Reserve manual
@Res()streaming for SSE or custom protocols, and reach for{ passthrough: true }when you only need a header or cookie alongside a normal return. - Set
Content-TypeandContent-Dispositionexplicitly for streamed files so browsers handle them predictably.