AppConfig feature flags let you switch on new features in Lambda without redeploying, and they do it step by step instead of for everyone at once. That last part is why I stop using Parameter Store once a switch changes real behavior. A value in Parameter Store applies everywhere immediately. A flag in AppConfig first reaches a share of your execution environments, gets watched, and rolls back by itself if errors show up.

In this article you build all of that with AWS CDK and TypeScript: a small price API for a fictional cinema with a Sunday discount as a feature flag, a kill switch, a gradual rollout and a CloudWatch alarm that pulls the brake when things go wrong. You will also learn what it costs and where I would have tripped up myself.

In short

AWS AppConfig delivers feature flags to your Lambda functions in a controlled way: the AppConfig Agent extension keeps the flags in a local cache, and a deployment strategy rolls changes out in stages, including automatic rollback through a CloudWatch alarm. The biggest pitfall is expecting a flag to apply everywhere instantly – by design, it does not.

What you will learn in this article

  • How AppConfig, the Lambda extension and deployment strategies work together
  • Create feature flags with CDK and read them safely in TypeScript
  • Build a gradual rollout with automatic rollback through an alarm
  • Set up a kill switch that takes effect in seconds instead of minutes
  • What AppConfig costs and which mistakes to avoid

What are feature flags, and when is Parameter Store not enough?

A feature flag (or feature toggle) is a switch in your code: when it is on, the new path runs; when it is off, the old one does. Sounds trivial. The point is that you flip the switch at runtime without shipping new code. That separates two things that usually stick together: the deployment (code is in production) and the release (users see the feature).

For simple values I showed you in Parameter Store in Lambda: load configuration the right way how to load and cache switches and prices cleanly. That works well, but it has one property that hurts with risky changes: every change hits all functions at the same time, as soon as their cache expires. If the new path has a bug, every user is affected right away.

AWS AppConfig is part of AWS Systems Manager and is built for exactly this case. It checks new configurations against a schema before delivery, distributes them in stages according to a strategy, and can roll back when an alarm fires. My rule of thumb is therefore: Parameter Store is for configuration that rarely changes and is harmless. AppConfig is for switches that change behavior and can go wrong.

Parameter Store AppConfig feature flags
Change reaches all functions once the cache expires functions gradually, following a strategy
Validation none (except data type) JSON schema, limits per attribute
Automatic rollback no yes, through CloudWatch alarms
Access from Lambda SDK call or extension local HTTP call to the extension
Typical use hourly prices, log level, endpoints new features, experiments, kill switch

How do AppConfig feature flags work in Lambda?

AppConfig organizes everything in three levels. The application is the container, for example “cinema-tickets”. Below it sit environments such as “dev” and “prod”. The configuration profile holds the actual data, in our case of type AWS.AppConfig.FeatureFlags. Every change to the profile creates a new, immutable version, and only a deployment brings that version into an environment.

Diagram: Lambda handler reads AppConfig feature flags via the local agent; a CloudWatch alarm triggers rollback

The handler only asks the local agent; the agent asks AppConfig.

The AppConfig Agent extension

In Lambda you do not read the flags through the AppConfig API directly. Instead, you attach the AppConfig Agent extension to your function as a layer. A Lambda extension is a companion process that runs in the same execution environment as your handler. The agent starts during init, fetches the configuration, puts it into a local cache and serves it at http://localhost:2772. So your code only makes a local HTTP call that never leaves the environment.

In the background the agent checks for news at a fixed interval, every 45 seconds by default. If AppConfig is unreachable for a moment, it keeps serving the last known version. On top of that it handles the session management with StartConfigurationSession and GetLatestConfiguration, which you would otherwise have to build yourself.

Deployment strategy and bake time

The deployment strategy defines how fast a new version spreads. With a linear strategy of 20 percent over ten minutes, another fifth of the polling clients gets the new version every two minutes. In Lambda these clients are the individual execution environments, because each one has its own agent and its own cache. After 100 percent comes the bake time: an observation phase in which AppConfig keeps watching for alarms before it considers the deployment complete.

If a linked CloudWatch alarm fires during this time, AppConfig restores the previous version. The animation shows the flow in fast motion.

🎬 Gradual rollout with automatic rollback – this animated diagram only runs on my site: watch it on sascha-ms.de

Set up AppConfig feature flags with CDK: step by step

Now it gets practical. We build an HTTP API with a Lambda function that calculates cinema ticket prices. The flag sunday-discount grants a discount on Sundays, and the size of the discount lives as an attribute in the flag. The flag sales-stop is the kill switch: when it is on, the API answers with 503.

Prerequisites

  • Node.js 22 or newer and npm
  • An AWS account with configured credentials (aws configure or SSO)
  • An account and region bootstrapped once with npx cdk bootstrap
  • Basic knowledge of TypeScript and Lambda

Step 1: Create the project

Create an empty project and install CDK, TypeScript and the type definitions. tsx runs TypeScript directly without compiling first.

mkdir cinema-flags && cd cinema-flags npm init -y npm install aws-cdk-lib constructs npm install -D aws-cdk typescript tsx esbuild @types/node @types/aws-lambda mkdir -p bin lib src test flags 

Add a tsconfig.json and a cdk.json that tells CDK how to start the app:

{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022", "DOM"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", "types": ["node"] }, "include": ["bin", "lib", "src", "test"] } 
{ "app": "npx tsx bin/app.ts" } 

Step 2: Describe the flags as JSON

The feature flag format has three parts: version (always "1"), flags with the definitions and values with the current values. The constraints are especially useful: AppConfig rejects a new version if the discount is set to 80 percent, for example. A typo in the percentage never reaches production.

{ "version": "1", "flags": { "sunday-discount": { "name": "Sunday discount", "description": "New pricing rule: discount on all Sunday screenings", "attributes": { "discount-percent": { "constraints": { "type": "number", "minimum": 0, "maximum": 50, "required": true } } } }, "sales-stop": { "name": "Sales stop", "description": "Kill switch: temporarily rejects all price requests" } }, "values": { "sunday-discount": { "enabled": false, "discount-percent": 20 }, "sales-stop": { "enabled": false } } } 

Flag and attribute names must start with a lowercase letter and may contain letters, digits, underscores and hyphens. Both flags start switched off. That way the new code lands “dark” in production first.

Step 3: Read flags safely in the handler

The most important part of the code looks unremarkable: what happens if the agent does not answer? My answer is always the same. The function falls back to safe defaults, meaning yesterday’s behavior. In addition, I set a tight timeout so that a hanging call does not block the whole request.

// src/flags.ts // Reads feature flags through the local AppConfig Agent (Lambda extension). export interface TicketFlags { sundayDiscount: { enabled: boolean; discountPercent: number }; salesStop: { enabled: boolean }; } // Safe defaults: if the agent does not answer, behavior stays as it was. export const SAFE_DEFAULTS: TicketFlags = { sundayDiscount: { enabled: false, discountPercent: 0 }, salesStop: { enabled: false }, }; interface RawFlag { enabled?: boolean; [attribute: string]: unknown; } export function buildFlagsUrl(env: NodeJS.ProcessEnv = process.env): string { const port = env.AWS_APPCONFIG_EXTENSION_HTTP_PORT ?? "2772"; const app = env.APPCONFIG_APPLICATION; const stage = env.APPCONFIG_ENVIRONMENT; const profile = env.APPCONFIG_PROFILE; if (!app || !stage || !profile) { throw new Error("APPCONFIG_APPLICATION, APPCONFIG_ENVIRONMENT and APPCONFIG_PROFILE must be set"); } return `http://localhost:${port}/applications/${app}/environments/${stage}/configurations/${profile}`; } export function parseFlags(raw: Record<string, RawFlag>): TicketFlags { const sunday = raw["sunday-discount"] ?? {}; const stop = raw["sales-stop"] ?? {}; const percent = Number(sunday["discount-percent"] ?? 0); return { sundayDiscount: { enabled: sunday.enabled === true, // Second safeguard on top of the validation in AppConfig discountPercent: Number.isFinite(percent) ? Math.min(Math.max(percent, 0), 50) : 0, }, salesStop: { enabled: stop.enabled === true }, }; } export async function loadFlags(url: string, timeoutMs = 500): Promise<TicketFlags> { try { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); if (!response.ok) { console.warn(JSON.stringify({ msg: "AppConfig Agent returned an error", status: response.status })); return SAFE_DEFAULTS; } return parseFlags((await response.json()) as Record<string, RawFlag>); } catch (error) { console.warn(JSON.stringify({ msg: "AppConfig Agent unreachable, using defaults", error: String(error) })); return SAFE_DEFAULTS; } } 

For feature flag profiles the agent only returns the values, so an object like {"sunday-discount":{"enabled":false,"discount-percent":20}}. The function parseFlags turns that into a typed object and caps the discount once more on its own. Belt and braces, especially when money is involved.

Step 4: Pricing logic and handler

The pricing logic stays free of AWS, so you can test it without mocks. It receives the flags as a parameter:

// src/pricing.ts // Pure pricing logic without AWS dependencies, so it is easy to test. import type { TicketFlags } from "./flags"; export type SeatType = "standard" | "premium"; const PRICE_CENT: Record<SeatType, number> = { standard: 1100, premium: 1450 }; export function quotePrice(seat: SeatType, tickets: number, showtime: Date, flags: TicketFlags): number { const base = PRICE_CENT[seat] * tickets; const isSunday = showtime.getUTCDay() === 0; if (flags.sundayDiscount.enabled && isSunday) { return Math.round(base * (1 - flags.sundayDiscount.discountPercent / 100)); } return base; } 

The handler loads the flags on every invocation. That is fine here, because the call only goes to the agent’s local cache. I build the URL once outside the handler, so missing environment variables show up during the cold start.

// src/price-handler.ts import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; import { buildFlagsUrl, loadFlags } from "./flags"; import { quotePrice, type SeatType } from "./pricing"; const FLAGS_URL = buildFlagsUrl(); export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => { const flags = await loadFlags(FLAGS_URL); if (flags.salesStop.enabled) { return { statusCode: 503, body: JSON.stringify({ message: "Ticket sales are paused right now. Please try again later." }) }; } const seat = (event.queryStringParameters?.seat ?? "standard") as SeatType; const tickets = Number(event.queryStringParameters?.tickets ?? "1"); const showtime = new Date(event.queryStringParameters?.showtime ?? Date.now()); if (!["standard", "premium"].includes(seat) || !Number.isInteger(tickets) || tickets < 1 || tickets > 10 || Number.isNaN(showtime.getTime())) { return { statusCode: 400, body: JSON.stringify({ message: "seat: standard or premium, tickets: 1 to 10, showtime: ISO date" }) }; } const priceCent = quotePrice(seat, tickets, showtime, flags); return { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ seat, tickets, priceCent, sundayDiscount: flags.sundayDiscount.enabled }), }; }; 

Step 5: The CDK stack

The stack contains the real work: application, strategy, function with layer, alarm, environment with monitor, the flag profile and the permissions. CDK ships ready-made L2 constructs for AppConfig that save a lot of boilerplate.

// lib/cinema-flags-stack.ts import { readFileSync } from "node:fs"; import { join } from "node:path"; import { CfnOutput, Duration, Stack, type StackProps } from "aws-cdk-lib"; import * as appconfig from "aws-cdk-lib/aws-appconfig"; import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2"; import { HttpLambdaIntegration } from "aws-cdk-lib/aws-apigatewayv2-integrations"; import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch"; import * as iam from "aws-cdk-lib/aws-iam"; import * as lambda from "aws-cdk-lib/aws-lambda"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; import type { Construct } from "constructs"; export class CinemaFlagsStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const appName = "cinema-tickets"; const stageName = "prod"; const profileName = "ticket-flags"; // 1. AppConfig application const app = new appconfig.Application(this, "CinemaApp", { applicationName: appName }); // 2. Rollout strategy: 20 % more every 2 minutes, then 5 minutes of observation const strategy = new appconfig.DeploymentStrategy(this, "GradualRollout", { deploymentStrategyName: "cinema-linear-20", rolloutStrategy: appconfig.RolloutStrategy.linear({ growthFactor: 20, deploymentDuration: Duration.minutes(10), finalBakeTime: Duration.minutes(5), }), }); // 3. Lambda function with the AppConfig Agent extension as a layer. // The layer list in CDK can lag behind the latest version: // "cdk deploy -c appConfigLayerArn=<ARN>" overrides it. const layerArn: string | undefined = this.node.tryGetContext("appConfigLayerArn") ?? appconfig.Application.getLambdaLayerVersionArn(this.region, appconfig.Platform.ARM_64); if (!layerArn) { throw new Error(`No AppConfig layer ARN found for region ${this.region}`); } const priceFn = new NodejsFunction(this, "PriceFunction", { entry: join(__dirname, "../src/price-handler.ts"), runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, memorySize: 256, timeout: Duration.seconds(5), layers: [lambda.LayerVersion.fromLayerVersionArn(this, "AppConfigAgentLayer", layerArn)], environment: { APPCONFIG_APPLICATION: appName, APPCONFIG_ENVIRONMENT: stageName, APPCONFIG_PROFILE: profileName, AWS_APPCONFIG_EXTENSION_POLL_INTERVAL_SECONDS: "30", AWS_APPCONFIG_EXTENSION_PREFETCH_LIST: `/applications/${appName}/...
Source:https://dev.to/sascha-ms/appconfig-feature-flags-in-lambda-safe-rollouts-5faf