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).
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.
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.
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.
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.
Track an event
Record a conversion or custom metric with avsb.track.
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:
npm install @avsbhq/node @avsbhq/utilsOpen 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.
- Environments lives in the sidebar on its own, not inside Settings.
- Click Reveal to see the full key, then Copy to copy it.
Bootstrap the client and wrap your handler:
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 }), } })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:
// 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 })}Tracking an event:
// `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 })Re-scoping to a resolved user mid-handler:
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)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.
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.
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 }), }}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:
process.on('SIGTERM', async () => { await server.flush() await server.close()})Testing
Use @avsbhq/test to swap the real server for a mock in unit tests. No network call is made and evaluation is synchronous.
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) })})What's next
@avsbhq/nodeon npm: the package README, with the fullAvsbServerAPI.- Multi-context targeting: combine user, organization, and device contexts in one evaluation call.
- Sticky bucketing: guarantee users see the same variation (the same version of the test) across Lambda cold starts.
- Vercel Functions: the same pattern for Vercel serverless and Edge Functions.