AWS Lambda

This guide targets Lambda functions running on the Node.js 20.x (or later) runtime. By the end you will have a Lambda handler that does three things. It starts the A vs B SDK once per warm container, not on every request. It reads flags for each request with full targeting context. And it sends tracked events before the container freezes, using an explicit flush step you add yourself (Lambda does not do this for you).

1

Install

Add @avsbhq/node and the utility adapter to your Lambda package. Lambda bundles are zip files, so install as a production dependency and include node_modules in your bundle, or use a bundler such as esbuild.

2

Obtain your SDK key

Open your A vs B project and click Environments in the sidebar. Copy the SDK key for the environment you are targeting. Store it in Lambda environment variables so you can point a function at another environment without redeploying code.

3

Bootstrap the client outside the handler

Create AvsbServer outside the handler function, so Lambda reuses it across warm invocations instead of rebuilding it every time. On first boot, the SDK downloads the datafile: the small file listing every flag and its rules for your project. It refreshes the datafile in the background after that. Wrap your handler with lambdaHandler from @avsbhq/utils/middleware/lambda. It hands your handler a ready-to-use avsb client as a third argument.

4

Read a flag

getFlag returns a typed Flag<T> object. The generic type is inferred from the defaultValue argument, so no explicit type annotation is needed in most cases.

5

Track an event

Record a conversion or custom metric with avsb.track.

6

Identify a user

On Lambda each invocation scopes its own context using contextFrom. Do you need to change the context partway through the handler? This happens after you look up a user in a database, for example. Call forUser directly on the module-level server instance.

Install the packages:

Shell
npm install @avsbhq/node @avsbhq/utils
Shell1 line

Open your A vs B project and click Environments in the sidebar. It sits on its own there, next to Settings, not inside it. Each environment card shows a masked SDK key with Reveal and Copy buttons.

  1. Environments lives in the sidebar on its own, not inside Settings.
  2. Click Reveal to see the full key, then Copy to copy it.
Your SDK key is public
Your SDK key is a public identifier, not a secret: it is safe to ship in browser and mobile bundles, it can only fetch that environment's flag configuration and send events, and it can never read or change anything in your dashboard. Credentials covers all four A vs B credentials and which one to reach for.

Bootstrap the client and wrap your handler:

TypeScript
import { AvsbServer } from '@avsbhq/node'import { lambdaHandler } from '@avsbhq/utils/middleware/lambda'import { getRequestClient } from '@avsbhq/utils'import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '' })export const handler = lambdaHandler(  server,  {    contextFrom: (event: APIGatewayProxyEventV2) => ({      kind: 'user',      key: event.requestContext.authorizer?.jwt?.claims?.sub ?? 'anon',      plan: event.headers['x-user-plan'] ?? 'free',    }),  },  async (event, _ctx): Promise<APIGatewayProxyResultV2> => {    // The wrapper opened a request scope for this invocation, so the bound    // client is reachable from here and from anything this handler calls.    const avsb = getRequestClient()    const showNewCheckout = avsb?.getFlag('checkout_v2', false)    return {      statusCode: 200,      body: JSON.stringify({ showNewCheckout: showNewCheckout?.value ?? false }),    }  })
TypeScript28 lines
contextFrom is required

contextFrom builds the EvalContext from the incoming event, and lambdaHandler takes it as the second of three arguments: lambdaHandler(server, options, handler). Return undefined from it to skip A vs B for that invocation entirely: the handler still runs, and getRequestClient() returns null inside it.

Reading flags of different types:

TypeScript
// Inside the wrapped handler, or anything it calls:const avsb = getRequestClient()if (avsb) {  // Boolean flag: T inferred as boolean  const checkout = avsb.getFlag('checkout_v2', false)  if (checkout.value) {    // serve new checkout  }  // String flag: T inferred as string  const algo = avsb.getFlag('ranking_algo', 'bm25')  // JSON flag: provide a typed default  const config = avsb.getFlag('pricing_config', { tier: 'standard', seats: 1 })}
TypeScript15 lines

Tracking an event:

TypeScript
// `revenue` is money in major units; `value` is the separate numeric-metric// column (items in a cart, seats on a plan).getRequestClient()?.track('checkout_started', { revenue: 149.99 })
TypeScript3 lines

Re-scoping to a resolved user mid-handler:

TypeScript
import { db } from './db'const user = await db.getUser('u_123')const scopedAvsb = server.forUser({  kind: 'user',  key: user.id,  plan: user.plan,  orgId: user.orgId,})const flag = scopedAvsb.getFlag('checkout_v2', false)
TypeScript10 lines

Send events before the container freezes

Lambda containers do not receive SIGTERM during normal scale-in. They freeze instead, with no warning, and a frozen container never runs any more code. lambdaHandler does not flush events for you: it only sets up the per-request context, so anything you track sits in memory until something sends it.

The one mistake people make on this page

Trusting the background timer. avsb.track(...) queues the event, and by default a timer sends queued events roughly every 2 seconds. That works fine on a long-running server. On Lambda, the container can freeze before that timer next fires, and a frozen container never fires it again. The event is lost silently: no error, nothing in your logs.

The fix is one line, added to the same handler shown above: flush before you return.

TypeScript
async (event: APIGatewayProxyEventV2, _ctx: unknown): Promise<APIGatewayProxyResultV2> => {  const avsb = getRequestClient()  const showNewCheckout = avsb?.getFlag('checkout_v2', false)  // Waits for queued events to send before Lambda is allowed to freeze the  // container. Skip this and a burst of invocations can each freeze with  // events still queued.  await server.flush()  return {    statusCode: 200,    body: JSON.stringify({ showNewCheckout: showNewCheckout?.value ?? false }),  }}
TypeScript14 lines

If you use Lambda extensions or Provisioned Concurrency with shutdown hooks, those environments do receive SIGTERM. Flush there too, as a backstop for anything a mid-invocation freeze missed:

TypeScript
process.on('SIGTERM', async () => {  await server.flush()  await server.close()})
TypeScript4 lines

Testing

Use @avsbhq/test to swap the real server for a mock in unit tests. No network call is made and evaluation is synchronous.

TypeScript
import { TestData, createMockServer, flagsFromTestData } from '@avsbhq/test'import { describe, it, expect } from 'vitest'const td = TestData.flag('checkout_v2')  .booleanFlag()  .variationForUser('u_1', true)  .fallthroughVariation(false)const mockServer = createMockServer(flagsFromTestData([td.build()]))describe('handler', () => {  it('returns showNewCheckout true for u_1', async () => {    const scoped = mockServer.forUser({ kind: 'user', key: 'u_1' })    const flag = scoped.getFlag('checkout_v2', false)    expect(flag.value).toBe(true)  })  it('returns false for unknown user', async () => {    const scoped = mockServer.forUser({ kind: 'user', key: 'unknown' })    const flag = scoped.getFlag('checkout_v2', false)    expect(flag.value).toBe(false)  })})
TypeScript23 lines

What's next

Was this helpful?