/Docs

AWS Lambda integration

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 bootstraps the A vs B SDK once per warm container, reads flags per invocation with full targeting context, and flushes any pending events before the container freezes.

1

Install

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

terminal
bash
1npm install @avsbhq/node @avsbhq/utils
2

Obtain your SDK key

Open your A vs B project, navigate to Settings → Environments, and copy the Server SDK key for the environment you are targeting. Server SDK keys are secret — store them in Lambda environment variables or AWS Secrets Manager, never in source code.

3

Bootstrap the client outside the handler

Initialise AvsbServer at module scope so it is shared across warm invocations. The SDK fetches and caches the datafile on first boot and polls for updates in the background. Wrap your handler with lambdaHandler from @avsbhq/utils/middleware/lambda — it injects an initialised, per-invocation avsb helper as a third argument.

handler.ts
typescript
1import { AvsbServer } from '@avsbhq/node'
2import { lambdaHandler } from '@avsbhq/utils/middleware/lambda'
3import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from 'aws-lambda'
4
5const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
6
7export const handler = lambdaHandler(
8 { server },
9 async (
10 event: APIGatewayProxyEventV2,
11 _ctx: Context,
12 avsb
13 ): Promise<APIGatewayProxyResultV2> => {
14 // avsb is pre-scoped to the context built by contextFrom (see options below)
15 const showNewCheckout = avsb.getFlag('checkout-v2', false)
16
17 return {
18 statusCode: 200,
19 body: JSON.stringify({ showNewCheckout: showNewCheckout.value }),
20 }
21 }
22)
contextFrom option
Pass a contextFrom function to lambdaHandler to build the EvalContext from the incoming event. Without it the SDK uses an anonymous context and targeting rules based on user attributes will not fire.
handler.ts — with contextFrom
typescript
1export const handler = lambdaHandler(
2 {
3 server,
4 contextFrom: (event: APIGatewayProxyEventV2) => ({
5 kind: 'user',
6 key: event.requestContext.authorizer?.jwt?.claims?.sub ?? 'anon',
7 plan: event.headers['x-user-plan'] ?? 'free',
8 }),
9 },
10 async (event, _ctx, avsb) => {
11 const flag = avsb.getFlag('checkout-v2', false)
12 return { statusCode: 200, body: JSON.stringify({ value: flag.value }) }
13 }
14)
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.

handler.ts
typescript
1// Boolean flag — T inferred as boolean
2const checkout = avsb.getFlag('checkout-v2', false)
3if (checkout.value) {
4 // serve new checkout
5}
6
7// String flag — T inferred as string
8const algo = avsb.getFlag('ranking-algo', 'bm25')
9
10// JSON flag — provide a typed default
11const config = avsb.getFlag('pricing-config', { tier: 'standard', seats: 1 })
5

Track an event

handler.ts
typescript
1avsb.track('checkout_started', {
2 value: 149.99,
3 properties: { currency: 'usd', items: 3 },
4})
6

Identify a user

On Lambda each invocation scopes its own context via contextFrom. If you need to update the context mid-handler (for example after resolving a user from a database), call forUser directly on the module-level server instance:

handler.ts
typescript
1const user = await db.getUser(userId)
2const scopedAvsb = server.forUser({
3 kind: 'user',
4 key: user.id,
5 plan: user.plan,
6 orgId: user.orgId,
7})
8const flag = scopedAvsb.getFlag('checkout-v2', false)

Graceful shutdown

Lambda containers do not receive SIGTERM during normal scale-in; they freeze instead. The lambdaHandler wrapper calls server.flush() via context.callbackWaitsForEmptyEventLoop semantics automatically — you do not need additional teardown code in most setups.

If you have opted in to Lambda extensions or are using Provisioned Concurrency with graceful shutdown hooks, call flush explicitly:

handler.ts
typescript
1process.on('SIGTERM', async () => {
2 await server.flush()
3 await server.close()
4})

Testing

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

handler.test.ts
typescript
1import { TestData, createMockClient } from '@avsbhq/test'
2import { describe, it, expect } from 'vitest'
3
4const td = TestData.flag('checkout-v2')
5 .booleanFlag()
6 .variationForUser('u_1', true)
7 .fallthroughVariation(false)
8
9const mockServer = createMockClient({ flags: [td.build()] })
10
11describe('handler', () => {
12 it('returns showNewCheckout true for u_1', async () => {
13 const scoped = mockServer.forUser({ kind: 'user', key: 'u_1' })
14 const flag = scoped.getFlag('checkout-v2', false)
15 expect(flag.value).toBe(true)
16 })
17
18 it('returns false for unknown user', async () => {
19 const scoped = mockServer.forUser({ kind: 'user', key: 'unknown' })
20 const flag = scoped.getFlag('checkout-v2', false)
21 expect(flag.value).toBe(false)
22 })
23})

What's next