Nest on Serverless

Build the Nest app once per container, adapt the event to Express, and know what stops working when the loop is frozen.

Syntaxexport const handler: Handler = async (event, context, callback) => (server ??= await bootstrap())(event, context, callback);

A function platform (AWS Lambda, Cloud Functions) calls an exported handler once per event and freezes the container in between. A Nest application is the opposite shape: one long-lived object graph, built at startup, serving requests for days. Running one inside the other means building the app once per container and reusing it.

How the bridge works

app.init() builds the module graph and runs the onModuleInit and onApplicationBootstrap hooks without binding a port — app.listen() would open a socket nothing can reach. serverlessExpress then wraps the underlying Express instance and turns the platform's event into a request it can serve. Inside that request nothing changes: middleware, then guards, then interceptors, then pipes, then the handler, then interceptors again, then exception filters.

Cold starts

The first invocation in a new container pays for every provider factory and every async provider: SDK clients, database pools, a config factory that calls a parameter store. Cache the adapter in a module-scope variable so later invocations skip all of it. NestJS 12 bundles with nest build --builder rspack; the old --webpack flag and "webpack": true in nest-cli.json are deprecated, and the bundler packages are now optional peer dependencies you install yourself.

When not to

Nothing runs between invocations. @Cron jobs, BullMQ workers, WebSocket gateways and queue pollers never fire, and shutdown hooks never run. A whole monolith behind one function also repays its full startup cost on every cold start.

Example

Example · typescript
// src/lambda.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import serverlessExpress from '@codegenie/serverless-express';
import type { Callback, Context, Handler } from 'aws-lambda';
import { AppModule } from './app.module';

// Module scope: this survives between invocations in the same container,
// which is what makes a warm call cheap.
let server: Handler;

async function bootstrap(): Promise<Handler> {
  const app = await NestFactory.create(AppModule);

  // Globals still have to be registered. They are not read from main.ts,
  // which the function never runs.
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  // init() builds the graph and runs onModuleInit / onApplicationBootstrap.
  // Do not call listen(): there is no port to serve on.
  await app.init();

  const expressApp = app.getHttpAdapter().getInstance();
  return serverlessExpress({ app: expressApp });
}

export const handler: Handler = async (
  event: unknown,
  context: Context,
  callback: Callback,
) => {
  // Longhand, as the Nest docs write it:
  server = server ?? (await bootstrap());
  // Shorthand for exactly the same assignment:
  //   server ??= await bootstrap();
  // Both assign only when server is null or undefined, so bootstrap runs
  // once per container rather than once per request.
  return server(event, context, callback);
};

When to use it

  • Run a small internal API as a single Lambda behind API Gateway instead of a container.
  • Handle an SQS or S3 trigger with the same services the HTTP app uses, through createApplicationContext.
  • Cut cold start time by bundling the function with nest build --builder rspack.
  • Decide against serverless for an app whose scheduled jobs and WebSocket gateway must keep running.

More examples

A function with no HTTP at all

The lighter option when the function is triggered by a queue, a schedule or a storage event rather than by HTTP. You keep dependency injection, configuration and your services, and drop the entire HTTP layer from the cold start. Note the trade: no guards and no pipes, so validate the payload explicitly inside the service.

Example · typescript
// src/sqs-handler.ts
import { NestFactory } from '@nestjs/core';
import type { SQSEvent, SQSBatchResponse } from 'aws-lambda';
import { AppModule } from './app.module';
import { OrdersService } from './orders/orders.service';

let orders: OrdersService;

async function getOrdersService(): Promise<OrdersService> {
  // createApplicationContext builds the DI container with no HTTP server,
  // no router and no Express. Guards, interceptors and pipes do not exist
  // here, because there is no request for them to wrap.
  const app = await NestFactory.createApplicationContext(AppModule, {
    logger: ['error', 'warn'],
  });
  return app.get(OrdersService);
}

export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
  orders ??= await getOrdersService();

  const batchItemFailures: SQSBatchResponse['batchItemFailures'] = [];
  for (const record of event.Records) {
    try {
      await orders.confirm(JSON.parse(record.body));
    } catch {
      // Report the failure instead of throwing: with partial batch
      // responses enabled, only these messages return to the queue.
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };
};

Binary responses and non-HTTP event sources

The two options people reach for after the first deploy looks right but behaves oddly. binarySettings fixes garbled file downloads. eventSourceRoutes lets a queue message arrive as a POST to a normal controller, which means your guards, pipes and filters apply to it — the opposite trade from the application-context approach above.

Example · typescript
// src/lambda.ts (configuration detail)
import serverlessExpress from '@codegenie/serverless-express';
import type { Handler } from 'aws-lambda';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

let server: Handler;

async function bootstrap(): Promise<Handler> {
  const app = await NestFactory.create(AppModule);
  await app.init();

  return serverlessExpress({
    app: app.getHttpAdapter().getInstance(),

    // Without this, a PDF or image is base64-decoded as if it were text and
    // reaches the browser corrupted. Listed types are returned as binary.
    binarySettings: {
      contentTypes: ['application/pdf', 'image/*', 'application/octet-stream'],
    },

    // Route non-HTTP triggers into ordinary routes in the same app, so one
    // function serves the API and its event handlers.
    eventSourceRoutes: {
      AWS_SQS: '/events/sqs',
      AWS_EVENTBRIDGE: '/events/schedule',
    },

    // Stack traces in the response body: useful locally, never in production.
    respondWithErrors: process.env.NODE_ENV !== 'production',
  });
}

export const handler: Handler = async (event, context, callback) => {
  server ??= await bootstrap();
  return server(event, context, callback);
};

Reading the Lambda event from inside a service

The escape hatch for the platform details that have no HTTP equivalent: the AWS request id for correlating logs, and the remaining execution time. Checking the remaining time before starting an expensive step is how you return a clean 503 instead of being killed mid-write at the timeout.

Example · typescript
// src/observability/invocation.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { getCurrentInvoke } from '@codegenie/serverless-express';

@Injectable()
export class InvocationService {
  private readonly logger = new Logger(InvocationService.name);

  // The adapter stores the current event and context so code deep in the
  // graph can read them without threading them through every call.
  describe(): { requestId?: string; msRemaining?: number } {
    const { event, context } = getCurrentInvoke();

    const requestId = context?.awsRequestId;
    const msRemaining = context?.getRemainingTimeInMillis?.();

    this.logger.log({
      requestId,
      msRemaining,
      // API Gateway payload format v2 puts the route here.
      route: event?.requestContext?.routeKey,
    });

    return { requestId, msRemaining };
  }

  // Useful guard: stop a long job you cannot finish before the timeout.
  hasTimeFor(estimateMs: number): boolean {
    const { context } = getCurrentInvoke();
    const remaining = context?.getRemainingTimeInMillis?.() ?? Infinity;
    return remaining > estimateMs + 500; // leave headroom to respond
  }
}

Discussion

  • Be the first to comment on this lesson.