Skip to content

NestJS en serverless — Lambda, Vercel, Workers

TL;DR — Nest tourne très bien sur AWS Lambda derrière API Gateway ou Function URL, via @codegenie/serverless-express (fork maintenu de l'ex-@vendia/serverless-express). Le cold start est le seul vrai ennemi : 800 ms à 3 s sur Node 22, qu'on réduit à 200-400 ms via esbuild + tree-shaking, lazy load des modules lourds, provisioned concurrency. Vercel Functions accepte Nest mais limite à 50 MB unzipped et 10-15 s d'exécution (Hobby). Cloudflare Workers n'accepte PAS Nest tel quel — pas de Node API complète, pas de filesystem, pas de reflect-metadata « lourd » : il faut Hono ou Nitro. Container (Fargate, Cloud Run) reste compétitif au-dessus de 1 M requêtes/mois. Cet article tranche chaque axe.

🧠 Mental model — ASCII + analogie

L'analogie : Lambda est un taxi à la demande, container est une voiture de société qu'on garde. Le taxi est imbattable quand on roule 2 fois par jour ; au-dessus de 100 km par jour, la voiture de société revient moins cher et démarre instantanément. Le cold start, c'est attendre que le taxi arrive — supportable si on a le temps, désastreux si on est pressé (latence p99 sur un appel API critique).

                       ┌──────────────────────────────────┐
   API Gateway / ALB   │  Lambda (Node 22 runtime)        │
   ─────HTTP─────────► │                                  │
                       │  index.handler                   │
                       │      │                            │
                       │      ▼                            │
                       │  serverlessExpress(express)      │
                       │      │                            │
                       │      ▼                            │
                       │  NestFactory.create(AppModule)   │ ◄── singleton cached
                       │  (only on cold start)            │     between invocations
                       └──────────────────────────────────┘

   Cold start budget (Node 22, AppModule ~30 modules):
   ┌─────────────────────────────────────────────────┐
   │ Lambda init (VM, runtime)        80–120 ms      │
   │ require() bundle 8 MB esbuild    150–300 ms     │
   │ NestFactory.create + DI graph    200–500 ms     │
   │ DB pool ready (lazy)             skipped       │
   │ First request handling           20–50 ms       │
   │ TOTAL                            450 ms – 1 s   │
   └─────────────────────────────────────────────────┘

   Warm invocation: 5–30 ms (just the handler call).

L'idée fondamentale : on appelle NestFactory.create() UNE FOIS hors du handler, on le met en cache module-level. Lambda réutilise le même conteneur Node pour les invocations suivantes (« warm ») tant que rien ne tue le conteneur. Ce caching est la pierre angulaire — si on le rate, on paye le cold start à chaque appel et la latence devient inutilisable.

🛠️ Code minimal (ts)

L'entrée Lambda canonique.

ts
// src/lambda.ts
import { Handler, Context } from 'aws-lambda';
import serverlessExpress from '@codegenie/serverless-express';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import express from 'express';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';

let cachedHandler: Handler | undefined;

async function bootstrap(): Promise<Handler> {
  const expressApp = express();
  const nestApp = await NestFactory.create(AppModule, new ExpressAdapter(expressApp), {
    logger: ['error', 'warn', 'log'],
    bufferLogs: true,
  });
  nestApp.enableShutdownHooks();
  await nestApp.init();
  Logger.log(`Nest cold start ready`, 'Bootstrap');
  return serverlessExpress({ app: expressApp });
}

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

Le bundling esbuild via serverless-esbuild ou aws-cdk-lib/aws-lambda-nodejs.

ts
// infra/lambda-stack.ts (AWS CDK)
import { Stack, Duration } from 'aws-cdk-lib';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda';
import { HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { Construct } from 'constructs';

export class ApiStack extends Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    const fn = new NodejsFunction(this, 'NestFn', {
      entry: 'src/lambda.ts',
      handler: 'handler',
      runtime: Runtime.NODEJS_22_X,
      architecture: Architecture.ARM_64,
      memorySize: 1024,
      timeout: Duration.seconds(30),
      bundling: {
        minify: true,
        sourceMap: true,
        target: 'node22',
        format: 'cjs',
        externalModules: ['@nestjs/microservices', '@nestjs/websockets', 'cache-manager', 'class-transformer', 'class-validator'],
        nodeModules: ['reflect-metadata'],
        tsconfig: 'tsconfig.build.json',
        define: { 'process.env.NODE_ENV': '"production"' },
      },
      environment: {
        DATABASE_URL: process.env.DATABASE_URL!,
        NODE_OPTIONS: '--enable-source-maps',
      },
    });

    const api = new HttpApi(this, 'HttpApi');
    api.addRoutes({
      path: '/{proxy+}',
      methods: [HttpMethod.ANY],
      integration: new HttpLambdaIntegration('Integration', fn),
    });
  }
}

Pour gérer les secrets sans cold-start coûteux, on charge depuis Secrets Manager lazy.

ts
// src/config/secrets.provider.ts
import { Injectable, Logger } from '@nestjs/common';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

@Injectable()
export class SecretsProvider {
  private readonly log = new Logger(SecretsProvider.name);
  private readonly client = new SecretsManagerClient({});
  private cache = new Map<string, { value: string; expiresAt: number }>();

  async get(name: string): Promise<string> {
    const cached = this.cache.get(name);
    if (cached && cached.expiresAt > Date.now()) return cached.value;
    const res = await this.client.send(new GetSecretValueCommand({ SecretId: name }));
    const value = res.SecretString ?? '';
    this.cache.set(name, { value, expiresAt: Date.now() + 5 * 60_000 });
    this.log.log(`Loaded secret ${name}`);
    return value;
  }
}

L'instrumentation X-Ray ouvre les traces automatiquement.

ts
// src/main.ts (when running container, not lambda)
import 'aws-xray-sdk-core';
import AWSXRay from 'aws-xray-sdk-core';
AWSXRay.captureHTTPsGlobal(require('http'));
AWSXRay.capturePromise();

Dans Lambda, X-Ray s'active via la console (Tracing: Active) — aucun code requis pour les traces basiques. Pour tracer les sous-segments dans Nest, on injecte un interceptor.

ts
// src/observability/xray.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap, catchError, throwError } from 'rxjs';
import AWSXRay from 'aws-xray-sdk-core';

@Injectable()
export class XrayInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const http = context.switchToHttp();
    const req = http.getRequest();
    const segment = AWSXRay.getSegment();
    const sub = segment?.addNewSubsegment(`${req.method} ${req.route?.path ?? req.url}`);
    return next.handle().pipe(
      tap(() => sub?.close()),
      catchError((err) => {
        sub?.addError(err);
        sub?.close();
        return throwError(() => err);
      }),
    );
  }
}

Et pour la version Function URL (sans API Gateway), le handler simplifie : serverlessExpress détecte automatiquement le format event (v1, v2, ALB, Function URL).

ts
// src/function-url.ts
import { Handler } from 'aws-lambda';
import serverlessExpress from '@codegenie/serverless-express';
import { NestFactory } from '@nestjs/core';
import express from 'express';
import { ExpressAdapter } from '@nestjs/platform-express';
import { AppModule } from './app.module';

let cached: Handler | undefined;

export const handler: Handler = async (event, context) => {
  context.callbackWaitsForEmptyEventLoop = false;
  if (!cached) {
    const app = express();
    const nest = await NestFactory.create(AppModule, new ExpressAdapter(app));
    await nest.init();
    cached = serverlessExpress({
      app,
      respondWithErrors: process.env.NODE_ENV !== 'production',
      log: { error: console.error, warn: console.warn },
    });
  }
  return cached(event, context, () => {});
};

🎯 Patterns courants

Lazy init des dépendances lourdes. Prisma Client, AWS SDK v3 (chaque service est son propre paquet), bcrypt, sharp : tout ce qui pèse > 500 KB doit être importé dynamiquement au premier usage, pas au top-level. const { PrismaClient } = await import('@prisma/client'); dans le constructeur du module quand on a besoin. Gain typique : 100 à 300 ms sur le cold start.

Concrètement, un service Nest avec lazy import :

ts
// src/storage/s3.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';

@Injectable()
export class S3Service implements OnModuleInit {
  private client?: import('@aws-sdk/client-s3').S3Client;

  async onModuleInit(): Promise<void> {
    // Empty: defer SDK loading until first call.
  }

  private async getClient(): Promise<import('@aws-sdk/client-s3').S3Client> {
    if (this.client) return this.client;
    const { S3Client } = await import('@aws-sdk/client-s3');
    this.client = new S3Client({ region: process.env.AWS_REGION });
    return this.client;
  }

  async putObject(key: string, body: Uint8Array, bucket: string): Promise<void> {
    const c = await this.getClient();
    const { PutObjectCommand } = await import('@aws-sdk/client-s3');
    await c.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body }));
  }
}

Le coût du premier appel est ~80-150 ms (chargement du module SDK). Les appels suivants : ~5 ms. Pour des endpoints qui n'utilisent jamais S3, on ne paye jamais le coût.

Provisioned Concurrency. AWS Lambda permet de pré-warmer N instances. Coût supplémentaire (~$0.015 par GB-h provisioned), mais cold start réduit à zéro. À activer pour les endpoints critiques (login, checkout) en heures de pointe via Application Auto Scaling. Combiner avec un scheduler EventBridge pour scale down la nuit.

SnapStart. Pour Java initialement, étendu à Python et bientôt Node (preview 2025). Le runtime fait un snapshot mémoire après init, et chaque cold start restaure le snapshot. Diminue le cold start de 90 %. À surveiller en 2026 pour Node 22.

ARM64 / Graviton. Lambda sur ARM64 coûte 20 % moins cher et performe 10 à 20 % mieux que x86 pour la plupart des workloads Node. Choisir Architecture.ARM_64 par défaut. Attention aux dépendances binaires (sharp, bcrypt, argon2) : utiliser les builds prebuilt ARM64 ou compiler dans le CI sur ARM (GitHub Actions ubuntu-22.04-arm).

Bundling : esbuild > webpack. Webpack est lent et produit des bundles plus gros. Esbuild compile Nest en 200 à 500 ms et produit un bundle 30 à 50 % plus petit. Externals à exclure : modules optionnels Nest jamais utilisés (@nestjs/microservices si pas de microservices), class-validator/class-transformer si on utilise zod. tsc direct sans bundling = trop de fichiers, plus lent à require.

API Gateway vs Function URL vs ALB. Function URL : pas d'auth API Gateway, plus rapide (skip 1 hop), moins cher. Bon pour des endpoints internes ou des webhooks. API Gateway HTTP API : 70 % moins cher que REST API, supporte JWT/Lambda authorizers. ALB : si déjà présent dans l'infra, intégration native, supporte plus de patterns d'auth. En 2026, par défaut : Function URL pour endpoints simples, HTTP API pour production.

Pas de connexions persistantes vers la DB. Chaque cold start ouvre un pool DB. Si 100 Lambdas se réveillent en même temps, Postgres saute. Solutions : RDS Proxy (couche de pooling AWS), pgbouncer, Neon serverless driver (HTTP/WebSocket), Drizzle avec HTTP driver, Aurora Data API (REST). En 2026, Neon + @neondatabase/serverless est la voie royale pour Lambda + Postgres.

Exemple avec Drizzle + Neon HTTP driver, qui n'ouvre PAS de connexion TCP persistante.

ts
// src/db/db.service.ts
import { Injectable } from '@nestjs/common';
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema';

@Injectable()
export class DbService {
  readonly db: ReturnType<typeof drizzle<typeof schema>>;

  constructor() {
    const sql = neon(process.env.DATABASE_URL!);
    this.db = drizzle(sql, { schema });
  }
}

Chaque query devient un POST HTTP vers Neon. Latence : 5-15 ms en intra-region, 20-50 ms cross-region. Bien adapté à Lambda. Pas adapté à un workload haute fréquence (transactions par batch) où une connexion TCP serait plus efficace.

Vercel Functions. Nest sur Vercel marche, mais limitations : 50 MB unzipped (@aws-sdk/* peut dépasser), 10 s timeout Hobby / 60 s Pro / 900 s Enterprise, pas de WebSocket (utiliser Pusher/Ably), pas de Cron natif sous Hobby. Avantage : déploiement Git push, edge network global, intégration Next.js native. Pour un Nest pur sans Next, préférer Fly.io ou Cloud Run.

Cloudflare Workers. Nest ne tourne pas sur Workers sans tricks majeurs : pas de reflect-metadata propre (V8 isolates ≠ Node VM), pas de process.cwd() natif, pas de filesystem. Alternative : porter le module métier dans Hono (framework ESM-first, micro, qui ressemble à Nest sur certains points). Si on veut absolument Nest, Cloudflare Workers en mode Node compat (compatibility_flags = ["nodejs_compat_v2"]) commence à fonctionner mais reste fragile. Pour 2026, attendre que nodejs_compat couvre 95 % de l'API.

Cloud Run / Fargate alternative. Container sur Cloud Run : scale-to-zero, billing par requête, démarrage 200 ms à 1 s, supporte WebSocket et streaming. Fargate Spot : moins cher mais éphémère. Pour un Nest avec workers BullMQ, gateway WebSocket, et endpoints REST mélangés, Cloud Run est souvent le meilleur compromis prix/perf en 2026.

Warm-up. Un endpoint /warm interne pingé toutes les 5 minutes par EventBridge maintient une instance Lambda chaude. Coût : ~$0.20/mois par Lambda. Plus simple que Provisioned Concurrency pour des trafics modérés. Attention : ne réchauffe qu'UNE instance. Pour scale > 1, plusieurs schedule rules concurrentes avec déphasage. Plugin populaire : serverless-plugin-warmup.

yaml
# serverless.yml fragment
functions:
  api:
    handler: src/lambda.handler
    warmup:
      default:
        enabled: true
        concurrency: 3
        schedule: rate(5 minutes)
        prewarm: true

Observabilité. Logs : CloudWatch Logs (structuré JSON avec bufferLogs: true Nest). Metrics : CloudWatch Embedded Metric Format ou OpenTelemetry vers Honeycomb/Datadog. Traces : X-Ray ou OTLP. Erreurs : Sentry avec @sentry/serverless. Ne pas oublier context.callbackWaitsForEmptyEventLoop = false ou les logs Sentry async ne flush jamais avant la fin du handler.

Coûts. Lambda 1024 MB ARM64, 100 ms moyen, 1 M req/mois : ~$1.50. Le même workload sur Fargate (0.25 vCPU, 0.5 GB, 24/7) : ~$10/mois. Crossover vers 5-7 M req/mois selon les CPU/mémoire. Au-delà, container gagne. Pour des bursts ponctuels, Lambda reste imbattable.

Configuration mémoire / CPU. Sur Lambda, la mémoire détermine aussi la CPU allouée. 128 MB → ~1/12 vCPU. 1769 MB → 1 vCPU complet. 3008 MB → ~2 vCPU. Pour un workload Node CPU-bound (parsing JSON gros, hashing), monter à 1024-1769 MB peut PAYER en accélérant l'exécution suffisamment pour diviser le coût total. Outil officiel : aws-lambda-power-tuning (state machine Step Functions qui benchmarke automatiquement plusieurs configs).

OpenTelemetry au lieu de X-Ray. X-Ray est lock-in AWS. OpenTelemetry, désormais standard, expose les traces vers Datadog, Honeycomb, Grafana Tempo, AWS Distro for OpenTelemetry (ADOT). Lambda Layer ADOT fait l'instrumentation automatique sans modifier le code. Tradeoff : +50 ms cold start (initialisation du SDK OTel).

ts
// src/observability/otel.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

export const otelSdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'nest-lambda-api',
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.STAGE ?? 'prod',
  }),
  traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_ENDPOINT! }),
  instrumentations: [getNodeAutoInstrumentations({
    '@opentelemetry/instrumentation-fs': { enabled: false }, // noisy
  })],
});

otelSdk.start();

À charger AVANT import { AppModule } (donc le top du fichier lambda.ts).

🔄 Versions — Nest 7 → 11 + libs

AnnéeNestLambda NodeLib serverless-express
20207Node 12, 14@vendia/serverless-express 4.x
20218Node 14, 16@vendia/serverless-express 4.x
20229Node 16, 18@vendia/serverless-express 4.x devient lent à maintenir
202310Node 18, 20Fork @codegenie/serverless-express reprend le flambeau
202411Node 20, 22@codegenie/serverless-express 4.16+
202611/12Node 22, 24@codegenie/serverless-express 5.x stable, support Fastify

Versions clés en 2026 : @codegenie/serverless-express ≥ 4.16, aws-cdk-lib ≥ 2.150, @aws-sdk/* ≥ 3.600, esbuild ≥ 0.23, @neondatabase/serverless ≥ 0.10. Node 22 LTS est le runtime par défaut, Node 24 disponible mais à éviter en prod en early 2026.

⚠️ Pitfalls — 10 à connaître

  1. Bootstrap dans le handler. Coder export const handler = async (event) => { const app = await NestFactory.create(...); return serverlessExpress({app})(event); } paye le cold start à CHAQUE invocation. Toujours cacher la handler en variable module-level.

  2. Container réuse et état mutable. Lambda réutilise le même Node process pour N invocations consécutives. Une variable globale let counter = 0 survit entre invocations du même conteneur. Conséquence : ne JAMAIS stocker d'état utilisateur en variable module — c'est une faille de fuite cross-tenant. Caches autorisés uniquement pour des données partagées (config, secrets, schémas).

  3. Logs async non flush. Si on await quelque chose et qu'un log Sentry est en flight quand le handler retourne, Lambda freeze le process. context.callbackWaitsForEmptyEventLoop = false ferme le process tout de suite — perte de logs. Solution : await Sentry.flush(2000) avant return.

  4. Payload max 6 MB API Gateway. Lambda lui-même accepte 6 MB sync, 256 KB async. Pour uploader des fichiers > 6 MB, passer par presigned S3 URL, pas par Lambda direct.

  5. Timeouts non synchronisés. API Gateway HTTP API : timeout max 30 s. Lambda : 15 min. Si la Lambda est configurée 60 s mais API GW 30 s, le client voit timeout à 30 s tandis que Lambda continue (et facture).

  6. Bundle qui explose. Importer @aws-sdk v2 entier = 30 MB. V3 modulaire = 200 KB par service. Importer lodash (au lieu de lodash-es + tree-shaking) = 600 KB. Auditer avec esbuild --metafile + https://esbuild.github.io/analyze/.

  7. Cold start sur scale soudain. Trafic passe de 10 RPS à 1000 RPS en 5 secondes : Lambda spawn 990 nouveaux conteneurs, chacun fait son cold start. Latence p99 monte à 2 s pendant 30 s. Provisioned Concurrency anticipée OU pre-scaling via API Gateway throttling pour absorber.

  8. VPC = cold start fatal. Lambda dans un VPC privé (pour accéder à RDS non public) ajoute 500 ms à 2 s de cold start (création ENI). Solution : RDS Proxy en VPC, Lambda hors VPC mais utilisant Proxy via IP publique avec auth IAM, ou utiliser Aurora Serverless v2 + Data API (HTTPS, pas de VPC).

  9. Reflect-metadata global pollution. Nest dépend de reflect-metadata. Si plusieurs Lambdas partagent un Layer, la version doit être identique. Conflit ⇒ DI casse silencieusement. Bundler reflect-metadata directement dans le code, jamais en Layer.

  10. Cloudflare Workers tentation. « C'est le plus rapide, déployons Nest dessus. » Non. V8 isolate runtime ≠ Node. decorators marche partiellement, pas de cluster, pas de fs, pas de connexions DB longues. Si on veut Cloudflare, abandonner Nest pour Hono ou ElysiaJS sur Bun.

  11. Logs CloudWatch coûteux. Lambda + CloudWatch Logs facture l'ingestion à $0.50/GB. Une appli verbeuse (log.debug partout) peut coûter plus en logs qu'en compute. Configurer le logger Nest en info minimum en prod, exporter vers S3 + Athena pour des logs cold storage moins chers, ou utiliser un agent (Vector, Fluent Bit) qui filtre avant ingestion.

  12. Lambda derrière API Gateway WebSocket. Le pattern n'est PAS standard Nest : chaque connexion ouverte appelle une Lambda séparée ($connect, $disconnect, $default). On ne peut pas utiliser @WebSocketGateway. Pour du WebSocket en serverless, soit IoT Core, soit AppSync, soit Fargate avec ALB target group de type WebSocket.

  13. Réutilisation du process et OutOfMemory. Lambda réutilise le conteneur pendant des heures. Si l'app a une fuite mémoire (Map qui grandit, cache jamais purgé), elle est exécutée sur le même conteneur jusqu'à OOM (crash + cold start suivant). Surveiller MaxMemoryUsed dans les logs et adopter des structures à TTL (LRU caches lru-cache v10).

  14. Secrets en variables d'environnement. Lisible par toute personne avec accès AWS Console. Pour les secrets sensibles (clés API, JWT signing), préférer Secrets Manager ou Parameter Store avec chiffrement KMS. Coût : Secrets Manager $0.40/secret/mois + $0.05/10k API calls. Acceptable pour 10-20 secrets.

  15. Init script qui bloque. await db.migrate() dans le bootstrap = chaque cold start tente la migration. En cas d'erreur (lock pris par un autre Lambda concurrent), tout crashe. Les migrations doivent être un step CI séparé, jamais dans le bootstrap Lambda.

🧪 Testing

Tester du Nest serverless localement exige soit serverless-offline (plugin Serverless Framework), soit sam local invoke (AWS SAM), soit un harness fait main qui simule l'event API Gateway.

ts
// test/lambda.spec.ts
import { handler } from '../src/lambda';
import type { APIGatewayProxyEventV2, Context } from 'aws-lambda';

describe('Lambda handler', () => {
  const baseContext: Partial<Context> = {
    callbackWaitsForEmptyEventLoop: false,
    awsRequestId: 'test-request',
    functionName: 'nest-fn',
    getRemainingTimeInMillis: () => 30_000,
  };

  function buildEvent(path: string, method: 'GET' | 'POST', body?: unknown): APIGatewayProxyEventV2 {
    return {
      version: '2.0',
      routeKey: `${method} ${path}`,
      rawPath: path,
      rawQueryString: '',
      headers: { 'content-type': 'application/json' },
      requestContext: {
        http: { method, path, protocol: 'HTTP/1.1', sourceIp: '127.0.0.1', userAgent: 'jest' },
      } as any,
      body: body ? JSON.stringify(body) : undefined,
      isBase64Encoded: false,
    } as any;
  }

  it('returns 200 on /health', async () => {
    const res: any = await handler(buildEvent('/health', 'GET'), baseContext as Context, () => {});
    expect(res.statusCode).toBe(200);
  });

  it('warm reuses Nest instance (no rebuild)', async () => {
    const t1 = Date.now();
    await handler(buildEvent('/health', 'GET'), baseContext as Context, () => {});
    const cold = Date.now() - t1;

    const t2 = Date.now();
    await handler(buildEvent('/health', 'GET'), baseContext as Context, () => {});
    const warm = Date.now() - t2;

    expect(warm).toBeLessThan(cold / 3);
  });
});

Pour les tests d'intégration contre la vraie AWS, utiliser des stacks éphémères CDK (cdk deploy --context branch=pr-42) puis tear-down. Coût : quelques centimes par PR.

Pour des tests d'événement non HTTP (SQS, S3 PUT, EventBridge), construire l'event manuellement et appeler le handler directement.

ts
// test/sqs-handler.spec.ts
import { handler } from '../src/handlers/sqs-handler';
import type { SQSEvent } from 'aws-lambda';

it('processes SQS batch', async () => {
  const event: SQSEvent = {
    Records: [
      {
        messageId: '1',
        receiptHandle: 'r1',
        body: JSON.stringify({ jobId: 'job-1', payload: { foo: 'bar' } }),
        attributes: {} as any,
        messageAttributes: {},
        md5OfBody: '',
        eventSource: 'aws:sqs',
        eventSourceARN: 'arn:aws:sqs:eu-west-3:111:queue',
        awsRegion: 'eu-west-3',
      },
    ],
  };
  const res: any = await handler(event, { awsRequestId: 'x' } as any, () => {});
  expect(res.batchItemFailures).toEqual([]);
});

Pour mesurer le cold start en CI, lancer une stack jetable, invoquer la Lambda avec --invocation-type RequestResponse, mesurer init duration dans le log. Vérifier qu'on reste sous 1 seconde.

bash
aws lambda invoke --function-name nest-fn --payload '{}' /tmp/out.json
aws logs filter-log-events --log-group-name /aws/lambda/nest-fn --filter-pattern '"Init Duration"' --max-items 1

🎬 Cas d'usage concrets

FinTech — webhook Stripe sur Lambda

Qui : startup FinTech B2B, abonnements SaaS facturés via Stripe. Le webhook reçoit invoice.paid, customer.subscription.updated, payment_intent.failed et doit traiter en moins de 5 s avant que Stripe retry.

Problème : le trafic webhook est sporadique (10-30 req/min en moyenne, 800 req/min en fin de mois quand les factures partent). Maintenir un container ECS 24/7 coûte cher pour cette volumétrie. Lambda colle parfaitement.

ts
// src/lambda.ts
import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@codegenie/serverless-express';
import express from 'express';
import { AppModule } from './app.module';
import type { Handler } from 'aws-lambda';

let cached: Handler;

async function bootstrap(): Promise<Handler> {
  const app = await NestFactory.create(AppModule, { logger: ['error', 'warn'] });
  app.use(express.raw({ type: 'application/json' })); // raw body for Stripe signature
  await app.init();
  return serverlessExpress({ app: app.getHttpAdapter().getInstance() });
}

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

// src/stripe/webhook.controller.ts
@Controller('webhooks/stripe')
export class StripeWebhookController {
  @Post()
  async receive(@Headers('stripe-signature') sig: string, @Req() req: RawBodyRequest<Request>) {
    const event = this.stripe.webhooks.constructEvent(req.rawBody!, sig, process.env.STRIPE_WEBHOOK_SECRET!);
    await this.idempotency.once(event.id, () => this.dispatcher.handle(event));
    return { received: true };
  }
}

Gains : coût mensuel ~12$ pour 200 k invocations contre 80$ pour un ECS Fargate t3.small permanent. Scale automatique aux pics de fin de mois sans pré-provisionnement. Idempotency par event.id Stripe garantit zéro doublon en cas de retry Stripe.

Agent juridique — batch d'analyse de contrats sur Vercel

Qui : éditeur SaaS legaltech. Quand un cabinet uploade un lot de 50 contrats, un agent IA les analyse en parallèle (LLM + recherche de clauses risquées). Chaque contrat prend 30 à 90 s.

Problème : tenir 50 connexions ouvertes en parallèle sur une API Nest classique surchargerait l'instance. Vercel Edge Functions avec streaming + concurrence native via Promise.all permet de distribuer la charge sans gérer la scalabilité.

ts
// app/api/contracts/analyze-batch/route.ts (Vercel)
export const runtime = 'nodejs';
export const maxDuration = 300; // 5 min Vercel Pro

export async function POST(req: Request) {
  const { contractIds, sessionId } = await req.json();
  const stream = new ReadableStream({
    async start(controller) {
      const analyses = await Promise.allSettled(contractIds.map(async (id: string) => {
        const result = await fetch(`${process.env.NEST_API_URL}/contracts/${id}/analyze`, {
          method: 'POST',
          headers: { authorization: `Bearer ${process.env.INTERNAL_TOKEN}` },
        }).then((r) => r.json());
        controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ id, result })}\n\n`));
        return { id, result };
      }));
      controller.close();
    },
  });
  return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
}

Gains : 50 contrats analysés en 95 s (vs 25 minutes en séquentiel). Coût Vercel inférieur à 2$/mois pour 300 batches. L'API Nest reste maître de la logique métier, Vercel sert juste de fan-out distribué éphémère.

E-commerce — image processing à la demande

Qui : marketplace mode B2C, 200 vendeurs qui uploadent 5 000 photos par jour. Chaque photo doit être déclinée en 6 tailles + WebP/AVIF, avec watermark sur les photos premium.

Problème : sharp est CPU-bound. Faire ça en synchrone dans Nest sature la pod. Le faire en BullMQ ECS marche mais on paie l'instance H24 alors que la charge est centrée 9h-22h.

ts
// src/lambda-image.ts
import sharp from 'sharp';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});

export const handler = async (event: S3Event) => {
  for (const record of event.Records) {
    const key = decodeURIComponent(record.s3.object.key);
    const { Body } = await s3.send(new GetObjectCommand({ Bucket: record.s3.bucket.name, Key: key }));
    const buffer = Buffer.concat(await streamToArray(Body as Readable));
    const sizes = [320, 800, 1600, 2400];
    await Promise.all(sizes.flatMap((width) => [
      processVariant(buffer, key, width, 'webp'),
      processVariant(buffer, key, width, 'avif'),
    ]));
  }
};

async function processVariant(input: Buffer, key: string, width: number, format: 'webp' | 'avif') {
  const out = await sharp(input).resize(width).toFormat(format, { quality: 80 }).toBuffer();
  const outKey = `processed/${width}/${format}/${key}`;
  await s3.send(new PutObjectCommand({
    Bucket: process.env.OUT_BUCKET!,
    Key: outKey, Body: out,
    ContentType: `image/${format}`, CacheControl: 'public, max-age=31536000, immutable',
  }));
}

Gains : coût mensuel divisé par 4 (60$ vs 240$ pour ECS dimensionné aux pics). Latence inchangée car chaque photo est traitée en parallèle dans sa propre invocation Lambda. Trigger S3 native, zéro orchestration custom.

🛠️ Exemple end-to-end

Contexte : startup B2B vendant un outil de génération de devis automatisés via IA. Architecture serverless complète : webhook Stripe pour l'abonnement, API REST principale en Lambda, worker batch en Lambda déclenchée par SQS, scheduling cron via EventBridge, et SSE court via Lambda Function URL pour le streaming de réponse. Bootstrap réutilisable, cold start optimisé, idempotency partout.

ts
// src/main.lambda.ts — Common bootstrap, reused by all Lambda entrypoints
import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@codegenie/serverless-express';
import * as express from 'express';
import { ExpressAdapter } from '@nestjs/platform-express';
import { AppModule } from './app.module';

let cachedHandler: any;

export async function bootstrapServer() {
  if (cachedHandler) return cachedHandler;
  const expressApp = express();
  const app = await NestFactory.create(AppModule, new ExpressAdapter(expressApp), {
    logger: ['error', 'warn', 'log'],
    bodyParser: false,
  });
  app.use('/webhooks', express.raw({ type: 'application/json', limit: '5mb' }));
  app.use(express.json({ limit: '1mb' }));
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.init();
  cachedHandler = serverlessExpress({ app: expressApp });
  return cachedHandler;
}

// src/handlers/api.handler.ts — REST API entrypoint
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { bootstrapServer } from '../main.lambda';

export const handler: APIGatewayProxyHandlerV2 = async (event, context) => {
  context.callbackWaitsForEmptyEventLoop = false;
  const server = await bootstrapServer();
  return server(event, context);
};

// src/handlers/quote-batch.handler.ts — SQS-triggered batch processor
import type { SQSHandler } from 'aws-lambda';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { QuoteGenerationService } from '../quotes/quote-generation.service';

let appCache: any;

async function getApp() {
  if (appCache) return appCache;
  const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
  await app.init();
  appCache = app;
  return app;
}

export const handler: SQSHandler = async (event) => {
  const app = await getApp();
  const service = app.get(QuoteGenerationService);
  for (const record of event.Records) {
    const payload = JSON.parse(record.body);
    await service.processIdempotent({
      messageId: record.messageId,
      quoteRequestId: payload.quoteRequestId,
      userId: payload.userId,
    });
  }
};

// src/quotes/quote-generation.service.ts
@Injectable()
export class QuoteGenerationService {
  constructor(
    private readonly llm: LlmService,
    private readonly repo: QuoteRepository,
    private readonly idempotency: DynamoIdempotencyService,
  ) {}

  async processIdempotent(input: { messageId: string; quoteRequestId: string; userId: string }) {
    const acquired = await this.idempotency.tryAcquire(`quote:${input.messageId}`, 3600);
    if (!acquired) {
      this.log.warn(`Duplicate SQS delivery messageId=${input.messageId}`);
      return;
    }
    const request = await this.repo.findRequest(input.quoteRequestId);
    const generated = await this.llm.generateQuote(request);
    await this.repo.saveQuote(input.quoteRequestId, generated);
    await this.idempotency.confirm(`quote:${input.messageId}`);
  }
}

// src/handlers/stripe-webhook.handler.ts — Webhook Stripe
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';

export const handler: APIGatewayProxyHandlerV2 = async (event, context) => {
  context.callbackWaitsForEmptyEventLoop = false;
  const server = await bootstrapServer();
  return server(event, context);
};

// src/handlers/cron.handler.ts — EventBridge-triggered scheduled
import type { ScheduledHandler } from 'aws-lambda';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { SubscriptionMaintenanceService } from '../billing/subscription-maintenance.service';

export const handler: ScheduledHandler = async () => {
  const app = await NestFactory.createApplicationContext(AppModule);
  try {
    const service = app.get(SubscriptionMaintenanceService);
    await service.expireTrialsAndChargeRenewals();
  } finally {
    await app.close();
  }
};

// src/idempotency/dynamo-idempotency.service.ts
@Injectable()
export class DynamoIdempotencyService {
  private readonly ddb = new DynamoDBClient({});
  private readonly table = process.env.IDEMPOTENCY_TABLE!;

  async tryAcquire(key: string, ttlSec: number): Promise<boolean> {
    try {
      await this.ddb.send(new PutItemCommand({
        TableName: this.table,
        Item: {
          pk: { S: key },
          status: { S: 'PENDING' },
          ttl: { N: String(Math.floor(Date.now() / 1000) + ttlSec) },
        },
        ConditionExpression: 'attribute_not_exists(pk)',
      }));
      return true;
    } catch (e: any) {
      if (e.name === 'ConditionalCheckFailedException') return false;
      throw e;
    }
  }

  async confirm(key: string) {
    await this.ddb.send(new UpdateItemCommand({
      TableName: this.table,
      Key: { pk: { S: key } },
      UpdateExpression: 'SET #s = :s',
      ExpressionAttributeNames: { '#s': 'status' },
      ExpressionAttributeValues: { ':s': { S: 'COMPLETED' } },
    }));
  }
}

Quatre entrypoints Lambda distincts partageant un bootstrap commun, idempotency via DynamoDB pour SQS et webhooks, createApplicationContext pour les triggers non-HTTP (SQS, EventBridge) afin d'éviter le coût HTTP inutile. Cold start ramené sous 800 ms grâce au cache module-level et au bundling esbuild minimal. Coût mensuel total infrastructure (Lambda + DynamoDB + SQS + EventBridge) sous 30$ pour 500 k requêtes API + 50 k devis générés.


🔁 Quand utiliser / éviter

À utiliser quand : trafic burst-y (campagnes marketing, webhooks Stripe, API publique avec usage variable), MVP avec budget infra zéro, processing asynchrone via SQS/EventBridge (Lambda déclenchée par event, durée < 15 min), endpoints internes admin peu sollicités, edge computing géographiquement distribué (Lambda@Edge).

À éviter quand : trafic stable et important (> 5 M req/mois soutenu) — container gagne en coût et latence p99, WebSocket ou SSE long-lived — Lambda timeout 15 min et facturation à la seconde, ML inference avec modèle > 250 MB en RAM — coût mémoire prohibitif, workloads CPU-bound longs (vidéo encoding, big batch) — Fargate ou ECS sont meilleurs, latence p99 critique sub-100ms — cold start incompatible sauf Provisioned Concurrency coûteuse.

Le piège : « tout serverless ». Une appli SaaS sérieuse a besoin de jobs longs (cron, batch), de connexions persistantes (WebSocket gateway, GraphQL subscriptions), de scheduling fin. Mélanger Lambda pour les endpoints API + Fargate pour les workers + Cloud Run pour le gateway WebSocket donne le meilleur des deux mondes.

Décision matricielle simplifiée pour 2026 :

Use caseRecommandation
API publique burst-y < 5M req/moisLambda + HTTP API Gateway
API stable trafic constant > 10M req/moisCloud Run ou Fargate
Webhook receiver (Stripe, GitHub)Lambda Function URL
Cron job 1-10 minEventBridge + Lambda
Batch processing > 15 minFargate task ou ECS Step Function
WebSocket gatewayFargate + ALB ou Cloud Run
Edge logic ultra-faible latenceCloudflare Workers + Hono (pas Nest)
Worker BullMQ (queue Redis)Fargate sidecar du backend Cloud Run
LLM proxy streamingCloud Run (15 min timeout par défaut, peut être étendu)
Image processing (sharp)Lambda 3GB ARM64 avec Provisioned Concurrency

Surveiller en 2026 : AWS Lambda SnapStart Node (preview), Cloudflare Workers nodejs_compat_v2 (stable), Vercel Fluid Compute (modèle hybride container/lambda).

🤖 Servir des agents IA depuis Nest en serverless

C'est le point qui casse le plus de têtes en 2026 : un agent LLM est un workload long-vivant et streaming, et Lambda derrière API Gateway est un environnement court et bufferisé. Les deux modèles s'opposent. Un staff engineer ne « met pas son agent sur Lambda » par réflexe — il sait exactement où chaque morceau atterrit.

Le mismatch fondamental — streaming vs Lambda

Un appel à claude-sonnet-4-6 ou claude-opus-4-8 en mode agentique (boucle tool-use) dure 5 à 120 s et émet des tokens en continu. Trois contraintes serverless le rendent piégeux :

ContrainteEffet sur un agentVerdict
API Gateway bufferise la réponse (pas de chunked transfer)Le client ne voit RIEN jusqu'à la fin → pas de vrai streamingInterdit pour le streaming
API Gateway timeout 29 sUne boucle tool-use de 60 s coupe le client (Lambda continue, facture)Interdit pour l'agentique longue
Lambda Function URL + RESPONSE_STREAM invoke modeStreaming HTTP réel, jusqu'à 15 min✅ La seule porte serverless

La règle : streaming d'agent ⇒ Lambda Function URL en mode RESPONSE_STREAM, jamais API Gateway. Ou alors on sort de Lambda (Cloud Run, qui supporte le streaming HTTP nativement avec un timeout de 60 min).

Streaming de tokens via Lambda Function URL (awslambda.streamifyResponse)

Lambda expose une API spéciale awslambda.streamifyResponse (runtime Node 18+) qui transforme la réponse en flux. On la branche sur un client Anthropic streamé avec retries SDK activés.

ts
// src/handlers/agent-stream.handler.ts
import Anthropic from '@anthropic-ai/sdk';
import type { Writable } from 'node:stream';

// Le client est créé HORS du handler (réutilisé entre invocations warm).
// maxRetries gère 429/529 avec backoff exponentiel + jitter côté SDK.
const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY!, // injecté via Secrets Manager, pas en clair
  maxRetries: 4,
  timeout: 60_000,
});

// awslambda est un global injecté par le runtime Lambda en mode streaming.
declare const awslambda: {
  streamifyResponse: (
    fn: (event: any, responseStream: Writable, context: any) => Promise<void>,
  ) => any;
  HttpResponseStream: {
    from: (stream: Writable, metadata: { statusCode: number; headers: Record<string, string> }) => Writable;
  };
};

export const handler = awslambda.streamifyResponse(async (event, responseStream, context) => {
  context.callbackWaitsForEmptyEventLoop = false;

  // SSE headers — métadonnées DOIVENT être posées avant le premier write.
  const stream = awslambda.HttpResponseStream.from(responseStream, {
    statusCode: 200,
    headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
  });

  const { prompt } = JSON.parse(event.body ?? '{}');

  // AbortController câblé sur le timeout Lambda restant : on s'auto-coupe
  // ~1 s avant le hard kill pour flush proprement (sinon réponse tronquée non finalisée).
  const ac = new AbortController();
  const guard = setTimeout(() => ac.abort(), context.getRemainingTimeInMillis() - 1000);

  try {
    const mcpStream = anthropic.messages.stream(
      {
        model: 'claude-sonnet-4-6',
        max_tokens: 4096,
        messages: [{ role: 'user', content: prompt }],
      },
      { signal: ac.signal },
    );

    for await (const ev of mcpStream) {
      if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
        stream.write(`data: ${JSON.stringify({ token: ev.delta.text })}\n\n`);
      }
    }
    stream.write(`event: done\ndata: {}\n\n`);
  } catch (err) {
    // Abort déclenché par déconnexion client OU par le guard de timeout.
    stream.write(`event: error\ndata: ${JSON.stringify({ message: (err as Error).message })}\n\n`);
  } finally {
    clearTimeout(guard);
    stream.end();
  }
});

Points seniors non négociables :

  • Client module-level, pas dans un champ. new Anthropic() au top du fichier = créé une fois par conteneur, réutilisé warm. Le mettre dans un champ d'instance recréé à chaque requête gaspille la socket keep-alive.
  • maxRetries au SDK, pas à la main. Le SDK Anthropic retry les 429/500/529 avec backoff exponentiel + jitter. Ne jamais réimplémenter un retry naïf par-dessus (double-retry = tempête).
  • AbortController câblé sur getRemainingTimeInMillis(). Sans ça, un agent qui déborde le timeout est tué brutalement, le flux SSE n'est jamais finalisé, et côté client le EventSource voit une troncature silencieuse impossible à distinguer d'une fin normale.

Pourquoi pas NestFactory ici ?

Dans un handler streaming pur, on n'instancie pas tout AppModule : on veut un cold start minimal. Pattern senior : NestFactory.createApplicationContext + récupérer juste le LlmService DI'd, sans la couche HTTP Express.

ts
// src/handlers/agent-stream-nest.handler.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { LlmAgentService } from '../ai/llm-agent.service';
import type { Writable } from 'node:stream';

let ctxCache: Awaited<ReturnType<typeof NestFactory.createApplicationContext>> | undefined;

async function getAgent(): Promise<LlmAgentService> {
  ctxCache ??= await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
  return ctxCache.get(LlmAgentService);
}

export const handler = awslambda.streamifyResponse(async (event, responseStream, context) => {
  context.callbackWaitsForEmptyEventLoop = false;
  const stream = awslambda.HttpResponseStream.from(responseStream, {
    statusCode: 200,
    headers: { 'content-type': 'text/event-stream' },
  });
  const agent = await getAgent();
  const ac = new AbortController();
  const guard = setTimeout(() => ac.abort(), context.getRemainingTimeInMillis() - 1000);
  try {
    for await (const token of agent.runAgentLoop(JSON.parse(event.body ?? '{}'), ac.signal)) {
      stream.write(`data: ${JSON.stringify(token)}\n\n`);
    }
  } finally {
    clearTimeout(guard);
    stream.end();
  }
});

Le LlmAgentService est un provider Nest standard, injecté via forRootAsync (jamais new Anthropic() dans un champ) :

ts
// src/ai/anthropic.module.ts
import { Module, Global } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';

export const ANTHROPIC = Symbol('ANTHROPIC_CLIENT');

@Global()
@Module({
  providers: [
    {
      provide: ANTHROPIC,
      useFactory: (cfg: { apiKey: string }) =>
        new Anthropic({ apiKey: cfg.apiKey, maxRetries: 4, timeout: 60_000 }),
      inject: ['ANTHROPIC_CONFIG'],
    },
  ],
  exports: [ANTHROPIC],
})
export class AnthropicModule {}

La boucle tool-use côté serveur (agentic loop)

Un agent ne fait pas qu'un appel : il boucle messages.create → si stop_reason === 'tool_use', exécute l'outil, ré-injecte le résultat, recommence. En serverless, chaque tour ronge le budget de timeout — on borne le nombre de tours.

ts
// src/ai/llm-agent.service.ts (extrait — la boucle agentique)
import { Inject, Injectable } from '@nestjs/common';
import type Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from './anthropic.module';

@Injectable()
export class LlmAgentService {
  constructor(@Inject(ANTHROPIC) private readonly client: Anthropic) {}

  async *runAgentLoop(input: { prompt: string }, signal: AbortSignal) {
    const messages: Anthropic.MessageParam[] = [{ role: 'user', content: input.prompt }];
    const tools: Anthropic.Tool[] = [
      { name: 'get_pricing', description: 'Tarifs produit', input_schema: { type: 'object', properties: { sku: { type: 'string' } }, required: ['sku'] } },
    ];

    // MAX_TURNS borné : un agent en serverless ne peut pas boucler 50 fois,
    // le timeout Lambda (15 min) ou le coût le tueront. 6 tours = budget réaliste.
    for (let turn = 0; turn < 6; turn++) {
      if (signal.aborted) return;
      const res = await this.client.messages.create(
        { model: 'claude-sonnet-4-6', max_tokens: 2048, tools, messages },
        { signal },
      );
      yield { type: 'assistant_turn', turn, stop: res.stop_reason };

      if (res.stop_reason !== 'tool_use') {
        for (const block of res.content) {
          if (block.type === 'text') yield { type: 'text', text: block.text };
        }
        return;
      }

      messages.push({ role: 'assistant', content: res.content });
      const toolResults: Anthropic.ToolResultBlockParam[] = [];
      for (const block of res.content) {
        if (block.type === 'tool_use') {
          const out = await this.execTool(block.name, block.input); // votre logique métier
          toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(out) });
        }
      }
      messages.push({ role: 'user', content: toolResults });
    }
    yield { type: 'error', message: 'max_turns_exceeded' };
  }

  private async execTool(name: string, input: unknown): Promise<unknown> {
    /* dispatch vers vos services métier */ return {};
  }
}

Le travail lourd va dans BullMQ / SQS, pas dans le handler synchrone

Génération de devis IA de 90 s, batch de 50 contrats : ce ne sont pas des requêtes HTTP. Pattern de référence : l'endpoint Lambda court accepte, pousse un job (SQS ou BullMQ sur un worker Fargate), répond 202 { generationId }, et le client poll/SSE le résultat. Règles de production pour un job IA :

PréoccupationDécision senior
IdempotencyClé = generationId (pas messageId SQS, qui change au retry). tryAcquire DynamoDB avant tout appel LLM payant.
Retry cost-awareUn appel LLM raté coûte des tokens. Ne retry QUE les erreurs transitoires (429/529/timeout), JAMAIS un 400 invalid_request. Le SDK gère déjà les transitoires ; au niveau BullMQ, attempts: 2 max avec backoff exponentiel.
Partial outputSi le job meurt à 80 %, persister le texte déjà généré (PARTIAL status) pour reprise — ne pas tout re-générer (coût + temps doublés).
Cost guardCompteur de tokens/€ par tenant en amont (DynamoDB atomic counter). Au-delà du quota → rejet 429 AVANT l'appel Anthropic, pas après facturation.
Visibility timeout SQSDoit dépasser la durée max du job (ex. 120 s) sinon SQS redélivre pendant que le 1er traite encore → double génération.
ts
// Worker BullMQ (Fargate, pas Lambda) — job IA idempotent et cost-aware
processor.process('generate-quote', async (job) => {
  const { generationId, tenantId, prompt } = job.data;

  // 1. Idempotency clé sur generationId, pas sur l'id de message de queue
  if (!(await idempotency.tryAcquire(`gen:${generationId}`, 3600))) return { skipped: true };

  // 2. Cost guard AVANT l'appel payant
  if (await costGuard.exceeded(tenantId)) throw new UnrecoverableError('quota_exceeded');

  // 3. Reprise sur partiel
  const prior = await store.loadPartial(generationId);
  const result = await agent.complete(prompt, { resumeFrom: prior?.tokens, signal: job.signal });

  await store.saveFinal(generationId, result);
  await costGuard.charge(tenantId, result.usage); // input + output tokens réels
  await idempotency.confirm(`gen:${generationId}`);
  return { generationId, tokens: result.usage };
});

UnrecoverableError (BullMQ) court-circuite les retries : inutile de re-tenter un quota dépassé. Le job.signal (AbortSignal) propage l'annulation jusqu'à l'appel Anthropic si le job est drainé.

Exposer un endpoint MCP / agent au edge

Si vous exposez vos outils Nest à des agents tiers (Claude via MCP), l'endpoint MCP doit vivre où le streaming est possible (Function URL streaming ou Cloud Run) et porter, AU EDGE, les trois gardes : idempotency (clé client fournie), rate-limit (par clé d'API tenant) et cost-guard (quota tokens). Mettre ces gardes dans un Guard Nest est tentant mais en serverless on les veut le plus tôt possible — idéalement dans l'authorizer Lambda / le middleware avant même de booter tout AppModule, pour rejeter un abus sans payer le cold start.

Modèles Anthropic 2026 à connaître pour le dimensionnement : claude-opus-4-8 (flagship, raisonnement profond, le plus cher — réserver aux tâches complexes), claude-sonnet-4-6 (workhorse, meilleur rapport qualité/prix pour l'agentique en prod), claude-haiku-4-5 (rapide et économique — classification, routing, extraction). En serverless où chaque ms de latence compte au p99, router les tâches simples vers Haiku et n'escalader vers Sonnet/Opus que sur besoin réel divise la facture ET le temps de réponse.

🏋️ Exercices

Progression : on implémente, on durcit en production-grade, puis on casse et on répare. Chaque exercice suppose le précédent terminé.

1. Bootstrap caché et mesure du cold start (implement)

Objectif : prouver, chiffres à l'appui, que le cache module-level supprime le cold start sur les invocations warm.

Écris un lambda.ts avec cachedHandler ??= await bootstrap(), déploie sur Function URL, invoque 20 fois d'affilée, et trace init duration vs duration depuis CloudWatch. Puis casse volontairement le cache (bootstrap DANS le handler) et compare.

Indice/Solution : aws logs filter-log-events --filter-pattern '"Init Duration"'. Tu dois voir UNE seule ligne Init Duration sur 20 invocations dans la version cachée, 20 dans la version cassée. Le warm doit être < cold/3.

2. Streaming d'agent LLM sans API Gateway (production-grade)

Objectif : streamer les tokens de claude-sonnet-4-6 end-to-end via Lambda Function URL RESPONSE_STREAM, avec un client Angular qui rend les tokens au fil de l'eau.

Implémente le handler awslambda.streamifyResponse, branche anthropic.messages.stream, et un front qui consomme via fetch + getReader() + TextDecoder. Mesure le time-to-first-token (doit être < 1 s warm). Vérifie qu'API Gateway, lui, bufferise tout (preuve par l'absurde).

Indice/Solution : Function URL avec InvokeMode: RESPONSE_STREAM dans la config. Côté client, lis le ReadableStream chunk par chunk ; ne JAMAIS attendre response.json() qui bufferise. Time-to-first-token = horodatage du 1er data: reçu moins l'envoi.

3. AbortController bout-en-bout (break it then fix it)

Objectif : quand l'utilisateur clique « Stop » ou ferme l'onglet, l'appel Anthropic doit s'arrêter — donc cesser de facturer des tokens.

D'abord constate le bug : sans signal, fermer le client laisse la Lambda streamer jusqu'au bout (tu vois les tokens dans CloudWatch alors que personne n'écoute → tokens payés pour rien). Puis câble AbortController : un bouton Stop client → abort() local ET un cancel serveur, plus le guard getRemainingTimeInMillis() - 1000.

Indice/Solution : passe { signal: ac.signal } à anthropic.messages.stream. Détecte la déconnexion via l'erreur d'écriture sur le responseStream (write échoue quand le socket client est mort) → ac.abort(). Vérifie dans usage que les output_tokens chutent quand tu abort tôt.

4. Job IA idempotent et cost-aware sous retry SQS (production-grade)

Objectif : un même message SQS redélivré (visibility timeout dépassé) ne doit JAMAIS générer deux fois le devis ni facturer deux fois Anthropic.

Implémente tryAcquire/confirm DynamoDB sur generationId, un visibility timeout > durée max du job, un cost-guard atomique par tenant, et la reprise sur partiel. Force le double-delivery (visibility timeout à 5 s, job de 20 s) et prouve qu'une seule génération a lieu.

Indice/Solution : ConditionExpression: 'attribute_not_exists(pk)' rend PutItem atomique. Si ConditionalCheckFailedException → skip. Le cost-guard : UpdateItem ... ADD tokens :n (compteur atomique) avant l'appel, rollback si l'appel échoue.

5. VPC + DB pool storm (break it then fix it)

Objectif : reproduire l'effondrement Postgres au scale soudain, puis l'éliminer.

Mets la Lambda dans un VPC privé avec un pool TCP classique (pg.Pool), envoie 500 RPS d'un coup. Observe : cold start +1,5 s (ENI), puis too many connections Postgres. Répare en deux temps : (a) Neon HTTP driver (zéro connexion persistante), (b) si TCP obligatoire, RDS Proxy.

Indice/Solution : 500 conteneurs × pool de 10 = 5000 connexions → Postgres sature à ~100. Le driver @neondatabase/serverless transforme chaque query en POST HTTPS, plus de pool à saturer. RDS Proxy mutualise et met en file. Mesure la chute du p99.

6. Router multi-modèle cost-aware (production-grade, niveau staff)

Objectif : router automatiquement chaque requête vers claude-haiku-4-5 (simple), claude-sonnet-4-6 (standard) ou claude-opus-4-8 (complexe) et prouver l'économie.

Implémente un classifieur léger (longueur, présence d'outils requis, score de complexité) qui choisit le modèle, instrumente le coût réel (usage input+output tokens × tarif modèle) en CloudWatch EMF, et compare la facture « tout Opus » vs routée sur 1000 requêtes représentatives.

Indice/Solution : 70 % du trafic réel est trivial → Haiku. Le routing doit diviser la facture par 5-10× sans dégrader la qualité perçue. Émets une metric EMF tokens_cost_usd par modèle ; un dashboard CloudWatch montre la répartition. Garde un fallback : si Haiku renvoie une réponse de faible confiance, escalade vers Sonnet (re-prompt).

🎤 En entretien

Q : Pourquoi ne peut-on pas streamer les tokens d'un LLM via Lambda derrière API Gateway, et quelle est la parade ? R : API Gateway bufferise la réponse complète (pas de chunked transfer) et timeout à 29 s — le client ne voit rien avant la fin. La parade est Lambda Function URL en mode RESPONSE_STREAM via awslambda.streamifyResponse (streaming HTTP réel jusqu'à 15 min), ou sortir de Lambda vers Cloud Run qui supporte le streaming nativement.

Q : Une variable globale let cache = new Map() dans un handler Lambda — où est le danger, où est l'usage légitime ? R : Lambda réutilise le même process Node pour N invocations warm, donc la Map survit entre requêtes. Légitime pour des données partagées immuables (secrets, config, client SDK). Faille cross-tenant si on y stocke de l'état utilisateur — la requête du tenant B lirait la donnée du tenant A. Règle : caches de partagé seulement, jamais d'état par-requête.

Q : Un message SQS déclenche une génération LLM de 90 s. Quels trois pièges de production et leurs fixes ? R : (1) Visibility timeout < durée job → SQS redélivre pendant le traitement → double génération + double facturation : mettre le visibility timeout > durée max. (2) Idempotency sur le mauvais id : la clé doit être le generationId métier, pas le messageId SQS qui change au retry. (3) Retry aveugle : ne re-tenter que les erreurs transitoires (429/529/timeout), jamais un 400 ou un quota dépassé (UnrecoverableError BullMQ) — sinon on brûle des tokens en boucle.

Q : À 5 M requêtes/mois soutenues, Lambda ou container ? Comment tu raisonnes ? R : C'est précisément la zone de crossover. Lambda facture par invocation × durée × mémoire ; le container (Cloud Run/Fargate) facture à l'allumage continu. Sous ~5-7 M req/mois et trafic burst-y, Lambda gagne ; au-dessus, en trafic soutenu, le container gagne en coût ET en p99 (zéro cold start). Le vrai discriminant n'est pas que le volume : présence de WebSocket/SSE long, jobs > 15 min, ou exigence p99 < 100 ms basculent vers le container quel que soit le volume. Réponse senior : on benchmarke avec aws-lambda-power-tuning et on décide sur données, pas sur dogme — et souvent l'archi optimale est hybride (Lambda pour les endpoints burst, Fargate pour les workers/WebSocket).

🔗 Liens

  • @codegenie/serverless-express : https://github.com/CodeGenieApp/serverless-express
  • Nest docs serverless : https://docs.nestjs.com/faq/serverless
  • AWS Lambda Node 22 runtime : https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html
  • AWS CDK NodejsFunction : https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html
  • esbuild : https://esbuild.github.io/
  • Neon serverless driver : https://neon.tech/docs/serverless/serverless-driver
  • Vercel Functions limits : https://vercel.com/docs/functions/runtimes/node-js
  • Cloudflare Workers Node compat : https://developers.cloudflare.com/workers/runtime-apis/nodejs/
  • Cloud Run pricing : https://cloud.google.com/run/pricing
  • Lambda Provisioned Concurrency : https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html
  • AWS X-Ray SDK : https://docs.aws.amazon.com/xray/latest/devguide/xray-sdk-nodejs.html

Bibliothèque tech perso — Achref