Anthony Wentzel

GraphQL subscriptions over API Gateway WebSockets on Lambda

API Gateway WebSockets do not speak HTTP, and NestJS does not speak API Gateway. How we translated between them to run GraphQL subscriptions on Lambda.

From 2022 · 2 min read · Published September 10, 2026

For a few years our default backend was NestJS with GraphQL, deployed to AWS Lambda behind API Gateway. It was cheap at low traffic, scaled without anyone touching it, and fit the monorepos we were building. Then we needed GraphQL subscriptions.

Then a product needed realtime features, which in GraphQL means subscriptions, which means WebSockets. And Lambda doesn't hold connections.

How API Gateway WebSocket APIs actually work

API Gateway can hold the socket for you. It keeps the connection open and invokes your Lambda for each event, with a route key telling you what happened:

  • $connect when a client connects
  • $default for each message
  • $disconnect when it goes away

Each invocation is a standalone event with a connectionId. There's no HTTP request, no path, no method. The NestJS app inside the Lambda, running through @vendia/serverless-express, only understands HTTP requests. So in late 2022 I wrote a translator that runs before Nest sees anything.

Repack the event as a GraphQL request

The idea is to turn every WebSocket event into a plain POST /graphql, and turn connect and disconnect into mutations the app already knows how to handle:

graphql/lambda-websocket-parser.ts
export function repackWebSocketEvent(event: APIGatewayProxyEvent) {
  const { connectionId, routeKey } = event.requestContext
  if (!connectionId) return { isWebSocket: false, event }
 
  event.headers = { ...event.headers, 'content-type': 'application/json' }
  event.headers['x-connection-id'] = connectionId 
  event.httpMethod = 'POST'
  event.path = '/graphql'
 
  const mutation = (name: string) =>
    JSON.stringify({ query: `mutation { ${name}(connectionId: "${connectionId}") }` })
 
  if (routeKey === '$connect') event.body = mutation('connect')
  if (routeKey === '$disconnect') event.body = mutation('disconnect')
  // $default: the body is already a graphql-ws protocol message
 
  return { isWebSocket: true, event }
}

The connection ID rides along as a header, so resolvers can store it and later push messages back to that client through the API Gateway management API.

Repack the response too

The response has to be translated back. API Gateway expects a specific shape, and for the connect step it needs the sec-websocket-protocol header echoed back, or clients using the graphql-ws protocol will drop the connection right after opening it. That one cost us an afternoon.

Keep the app warm

Bootstrapping Nest on every invocation is slow. The handler builds the app once, caches it in module scope, and reuses it across invocations in the same container. It also sets context.callbackWaitsForEmptyEventLoop = false, so a pooled database connection doesn't keep the function alive after the response is sent.

Would I do it again?

The translator worked, and we reused it across several products from 2022 into 2023. But it was a lot of code to maintain for something that isn't the product.

Today I'd reach for a platform that supports long-lived connections natively. Vercel Functions support WebSockets now, for instance, or I'd use a managed realtime service and keep the API stateless. The lesson isn't "don't do this." It's that when you find yourself translating between two protocols, check whether the constraint forcing you to still exists.


Related: React Native CI/CD that only builds the platform that changed · Prompts are data: prompt management and versioning in the database