Managed Queues
SQS and Pub/Sub have no built-in Nest transporter, so you consume them from a provider or write a custom transport strategy.
NestFactory.createMicroservice<MicroserviceOptions>(AppModule, { strategy: new SqsServer(queueUrl) })A managed queue is a broker someone else runs: Amazon SQS, Google Pub/Sub. You send a message, the broker keeps it until a consumer takes it and confirms. Against BullMQ on your own Redis you give up fine-grained retry control and gain durability, cross-region delivery and no Redis to patch.
Nest does not ship one
A transporter is the adapter that turns broker messages into calls on handlers marked @EventPattern (fire and forget) or @MessagePattern (request/response). The Transport enum in NestJS 12 has exactly seven members: TCP, REDIS, NATS, MQTT, GRPC, RMQ, KAFKA. SQS and Pub/Sub are not among them, which leaves two honest options: an ordinary provider that polls the queue itself, or a custom transport strategy — a class extending Server so that pattern handlers work as usual.
Shorthand and longhand
{ transport: Transport.RMQ, options } is the shorthand: Nest's ServerFactory runs new ServerRMQ(options) for you. { strategy: new ServerRMQ(options) } is the same thing written out, and strategy is the only form available for a broker Nest does not ship.
Ordering and redelivery
There is no HTTP request here, so no middleware runs. Guards, interceptors and pipes still wrap a pattern handler in that order, and failures reach an RPC exception filter rather than an HTTP one. Delete or acknowledge a message only after the handler succeeds, and expect at-least-once delivery: the same message will arrive twice, so every handler must be idempotent.
Example
// src/orders/order-consumer.service.ts
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteMessageCommand,
Message,
ReceiveMessageCommand,
SQSClient,
} from '@aws-sdk/client-sqs';
import { SQS_CLIENT } from '../aws/aws.module';
import { OrdersService } from './orders.service';
Injectable()
export class OrderConsumer implements OnApplicationBootstrap, OnApplicationShutdown {
private readonly logger = new Logger(OrderConsumer.name);
private readonly queueUrl: string;
private running = false;
private loop: Promise<void> = Promise.resolve();
constructor(
Inject(SQS_CLIENT) private readonly sqs: SQSClient,
private readonly orders: OrdersService,
config: ConfigService,
) {
this.queueUrl = config.getOrThrow<string>('SQS_ORDERS_URL');
}
// Runs once every module is initialised, before the app accepts traffic.
onApplicationBootstrap(): void {
this.running = true;
this.loop = this.poll();
}
// Runs on SIGTERM when app.enableShutdownHooks() has been called.
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(`Draining on ${signal ?? 'close'}`);
this.running = false;
await this.loop; // let the in-flight batch finish before the process exits
}
private async poll(): Promise<void> {
while (this.running) {
// Long polling: one request waits up to 20s for work instead of
// returning empty immediately. 20 and 10 are the SQS maximums.
const { Messages = [] } = await this.sqs.send(
new ReceiveMessageCommand({
QueueUrl: this.queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
MessageSystemAttributeNames: ['ApproximateReceiveCount'],
}),
);
for (const message of Messages) {
await this.handle(message);
}
}
}
private async handle(message: Message): Promise<void> {
try {
await this.orders.confirm(JSON.parse(message.Body ?? '{}'));
// Delete only on success. Throwing instead leaves the message to
// reappear after the visibility timeout, which is the retry.
await this.sqs.send(
new DeleteMessageCommand({
QueueUrl: this.queueUrl,
ReceiptHandle: message.ReceiptHandle!,
}),
);
} catch (error) {
const attempts = message.Attributes?.ApproximateReceiveCount ?? '1';
this.logger.error(`Message ${message.MessageId} failed on attempt ${attempts}`, error);
// No delete, no rethrow: the queue's redrive policy moves it to the
// dead-letter queue once maxReceiveCount is reached.
}
}
}When to use it
- Consume an SQS queue of order events in a worker deployment that serves no HTTP traffic.
- Publish a domain event from an HTTP handler without waiting for the consumer.
- Keep @EventPattern handlers while swapping RabbitMQ for SQS behind a custom strategy.
- Drain in-flight messages on SIGTERM so a rolling deploy loses no work.
More examples
Publishing, and the FIFO fields that only apply to FIFO queues
The producer side, which is just a provider wrapping one SDK command. Use MessageGroupId when per-customer or per-aggregate ordering matters, and MessageDeduplicationId to make a retried publish harmless. Keeping a pattern field in the body is what lets the same queue feed several handlers later.
// src/orders/order-publisher.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs';
import { SQS_CLIENT } from '../aws/aws.module';
interface OrderCreated {
orderId: string;
customerId: string;
totalCents: number;
}
@Injectable()
export class OrderPublisher {
private readonly queueUrl: string;
constructor(
@Inject(SQS_CLIENT) private readonly sqs: SQSClient,
config: ConfigService,
) {
this.queueUrl = config.getOrThrow<string>('SQS_ORDERS_URL');
}
async orderCreated(event: OrderCreated): Promise<void> {
await this.sqs.send(
new SendMessageCommand({
QueueUrl: this.queueUrl,
// Carry a pattern name yourself: SQS has no concept of one.
MessageBody: JSON.stringify({ pattern: 'order.created', data: event }),
MessageAttributes: {
schemaVersion: { DataType: 'String', StringValue: '1' },
},
// FIFO queues only (the URL ends in .fifo). One group is processed in
// order; different groups run in parallel. SQS rejects these two
// fields on a standard queue.
MessageGroupId: event.customerId,
MessageDeduplicationId: event.orderId,
}),
);
}
}A custom transport strategy, so @EventPattern works over SQS
Worth the extra class when you want handlers to look like every other Nest microservice handler: @EventPattern('order.created') in a controller, with guards and interceptors applied by the framework rather than by your polling loop. The interface is small — listen, close, on, unwrap — and Server gives you getHandlerByPattern and handleEvent to dispatch with.
// src/sqs/sqs.server.ts
import { BaseRpcContext, CustomTransportStrategy, Server } from '@nestjs/microservices';
import {
DeleteMessageCommand,
ReceiveMessageCommand,
SQSClient,
} from '@aws-sdk/client-sqs';
type SqsEvents = { error: (err: unknown) => void };
export class SqsServer extends Server<SqsEvents> implements CustomTransportStrategy {
// Identifies this transport in logs and in processing hooks.
transportId = Symbol('SQS');
private readonly sqs: SQSClient;
private running = false;
constructor(private readonly queueUrl: string, region: string) {
super();
this.sqs = new SQSClient({ region });
}
// Called by app.listen(). Invoke the callback once you are ready.
listen(callback: (...optionalParams: unknown[]) => void): void {
this.running = true;
void this.poll();
callback();
}
// Called on application shutdown.
close(): void {
this.running = false;
this.sqs.destroy();
}
// Required by Server. Throwing is legitimate when there is nothing to emit.
on<K extends keyof SqsEvents>(event: K, callback: SqsEvents[K]): never {
throw new Error('SqsServer does not expose broker events');
}
// Required by Server: hands back the underlying client.
unwrap<T>(): T {
return this.sqs as T;
}
private async poll(): Promise<void> {
while (this.running) {
const { Messages = [] } = await this.sqs.send(
new ReceiveMessageCommand({
QueueUrl: this.queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
}),
);
for (const message of Messages) {
const { pattern, data } = JSON.parse(message.Body ?? '{}');
// messageHandlers was filled from the @EventPattern decorators.
if (!this.getHandlerByPattern(pattern)) continue;
// handleEvent runs the handler through its guards, interceptors,
// pipes and exception filters. The context is whatever you pass.
await this.handleEvent(pattern, { pattern, data }, new BaseRpcContext([message]));
await this.sqs.send(
new DeleteMessageCommand({
QueueUrl: this.queueUrl,
ReceiptHandle: message.ReceiptHandle!,
}),
);
}
}
}
}
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
// Longhand: you build the strategy. The only form for SQS.
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
strategy: new SqsServer(process.env.SQS_ORDERS_URL!, process.env.AWS_REGION!),
});
// Shorthand, for the seven brokers Nest ships:
// { transport: Transport.RMQ, options: { urls: ['amqp://localhost'] } }
// Nest's ServerFactory turns that into new ServerRMQ(options) -- the same
// object you would pass as `strategy` yourself.
void Transport.RMQ;
await app.listen();
}
void bootstrap();Google Pub/Sub: a push-style subscription as a provider
Pub/Sub pushes over a streaming connection, so the shape differs from SQS: an event listener instead of a receive loop, ack/nack instead of DeleteMessage, and flowControl.maxMessages instead of MaxNumberOfMessages. Reach for this when the service already runs on Google Cloud. Note that ack() after a thrown error is the most common bug here: it silently drops the message.
// src/events/pubsub.consumer.ts
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Message, PubSub, Subscription } from '@google-cloud/pubsub';
import { OrdersService } from '../orders/orders.service';
@Injectable()
export class PubSubConsumer implements OnApplicationBootstrap, OnApplicationShutdown {
private readonly logger = new Logger(PubSubConsumer.name);
private readonly pubsub: PubSub;
private subscription?: Subscription;
constructor(
private readonly orders: OrdersService,
private readonly config: ConfigService,
) {
this.pubsub = new PubSub({ projectId: this.config.getOrThrow<string>('GCP_PROJECT_ID') });
}
onApplicationBootstrap(): void {
// The client streams messages to you; there is no polling loop here.
this.subscription = this.pubsub.subscription('orders-sub', {
flowControl: { maxMessages: 20 }, // in-flight cap, your backpressure knob
});
this.subscription.on('message', async (message: Message) => {
try {
// message.data is a Buffer, not a string.
await this.orders.confirm(JSON.parse(message.data.toString('utf8')));
message.ack();
} catch (error) {
this.logger.error(`Message ${message.id} failed`, error);
// deliveryAttempt is populated only when the subscription has a
// dead-letter policy attached.
message.nack(); // redelivered immediately rather than after the ack deadline
}
});
this.subscription.on('error', (error) => this.logger.error('Subscription error', error));
}
async onApplicationShutdown(): Promise<void> {
await this.subscription?.close(); // stops delivery, waits for callbacks in flight
await this.pubsub.close();
}
}
Discussion