Photo by Karan Suthar on Unsplash
AppConfig feature flags in AWS SDK v3 come from @aws-sdk/client-appconfigdata. Call StartConfigurationSessionCommand once for an application, environment and profile, then poll GetLatestConfigurationCommand with the token it returns. Every response carries a new single-use token and a poll interval, and an empty body means nothing changed, so keep the last flags you parsed.
AWS AppConfig lets you flip a feature on for 10% of traffic, watch an alarm, and roll back without a deploy. Reading those flags from code looks simple, but the data API is session based, charges per call and returns nothing when nothing changed, which trips up naive GetLatestConfiguration wrappers. This guide is for Node.js and TypeScript developers who want to read AppConfig feature flags with AWS SDK v3 correctly: a cached, typed client, Lambda usage, the Lambda extension alternative, IAM and what it costs.
The samples were type-checked with strict tsc against @aws-sdk/client-appconfigdata 3.1142.0 in September 2026 and tested with aws-sdk-client-mock: shared concurrent calls, an unchanged (empty) response and an expired token.
Which AppConfig API should you call for feature flags?
| Option | How it works | Use it when |
|---|---|---|
AppConfig Data API (StartConfigurationSession + GetLatestConfiguration) |
Your code holds the session token, polls and caches | Containers or servers without the agent, scripts, tests |
| AWS AppConfig Agent (Lambda extension, ECS and EKS agent) | A local HTTP endpoint caches and polls for you | Lambda and containers where you can add it; AWS recommends it |
GetConfiguration (@aws-sdk/client-appconfig) |
Deprecated | Never for feature flags: AWS documents that it can’t return feature flag data |
The API reference is direct about it: AWS recommends the agent and says direct Data API calls are for cases where the agent isn’t possible. The rest of this guide covers the SDK path properly and then shows the extension version, so you can pick. For plain settings and secrets rather than flags, reading SSM parameters with AWS SDK v3 and getting a Secrets Manager secret value with SDK v3 are the better fits.
Prerequisites
- An AppConfig application, an environment and a configuration profile of type
AWS.AppConfig.FeatureFlags, with at least one deployment to the environment. Until something is deployed there’s nothing to read. - Node.js 18 or later with
@aws-sdk/client-appconfigdata. - Credentials the SDK can find; how AWS SDK v3 credential providers work explains the lookup order.
How to read AppConfig feature flags with AWS SDK v3, step by step
- Start a session
StartConfigurationSessionCommandwith application, environment and profile, each by name or ID. Optionally setRequiredMinimumPollIntervalInSeconds(15 to 86,400). - Get the configurationPass
InitialConfigurationTokentoGetLatestConfigurationCommand. The first call returns the flags as JSON bytes. - Swap the tokenStore
NextPollConfigurationToken. Each token works for one call only and expires after 24 hours. - Wait before pollingDon’t call again until
NextPollIntervalInSecondshas passed. The default is 60 seconds. - Keep what you have on an empty bodyAn empty
Configurationmeans your copy is current, not that the flags were deleted.
What does a deployed feature flag profile look like?
In AppConfig you define flags with names, descriptions, attributes and constraints. When your application retrieves a deployed version, AWS strips the definitions and returns a simple map: one key per flag, enabled as true or false, and the flag’s attributes only when it’s enabled.
{
"newCheckout": { "enabled": true },
"freeShipping": { "enabled": true, "threshold": 75 },
"betaSearch": { "enabled": false }
}
Flag and attribute keys must start with a lowercase letter and can hold up to 64 characters; each flag holds at most 25 attributes, and attribute values are strings, numbers, booleans or arrays of strings or numbers. The Flags type in the client below mirrors that shape.
Example: a feature flag client with caching and token rotation
// feature-flags.ts: read AWS AppConfig feature flags with the AppConfig Data API (AWS SDK v3).
// Starts one configuration session, reuses the last good flags while the poll interval hasn't passed,
// keeps them when GetLatestConfiguration returns an empty body (no change), and restarts the session
// if the token is rejected (tokens are single use and expire after 24 hours).
import {
AppConfigDataClient,
StartConfigurationSessionCommand,
GetLatestConfigurationCommand,
BadRequestException,
} from "@aws-sdk/client-appconfigdata";
/** Shape of a deployed AWS.AppConfig.FeatureFlags profile: one entry per flag key. */
export type FlagValue = { enabled: boolean } & Record<string, string | number | boolean | string[] | number[]>;
export type Flags = Record<string, FlagValue>;
export interface FlagSource {
application: string; // application name or ID
environment: string; // environment name or ID
profile: string; // feature flag configuration profile name or ID
minPollSeconds?: number; // 15 to 86,400; the session refuses faster polling
}
export class FeatureFlags {
private token: string | undefined;
private flags: Flags = {};
private nextPollAt = 0;
private inFlight: Promise<Flags> | undefined;
public version: string | undefined;
private readonly source: FlagSource;
private readonly defaults: Flags;
private readonly client: AppConfigDataClient;
constructor(source: FlagSource, defaults: Flags = {}, client = new AppConfigDataClient({})) {
this.source = source;
this.defaults = defaults;
this.client = client;
}
/** All flags. Calls AppConfig at most once per poll interval; concurrent callers share one request. */
async all(): Promise<Flags> {
if (Date.now() < this.nextPollAt) return this.flags;
this.inFlight ??= this.poll().finally(() => {
this.inFlight = undefined;
});
return this.inFlight;
}
async isEnabled(key: string): Promise<boolean> {
const flags = await this.all();
return (flags[key] ?? this.defaults[key])?.enabled === true;
}
async attribute<T extends string | number | boolean | string[] | number[]>(key: string, name: string, fallback: T): Promise<T> {
const flag = (await this.all())[key] ?? this.defaults[key];
return flag?.enabled ? ((flag[name] as T | undefined) ?? fallback) : fallback;
}
private async startSession(): Promise<string> {
const res = await this.client.send(
new StartConfigurationSessionCommand({
ApplicationIdentifier: this.source.application,
EnvironmentIdentifier: this.source.environment,
ConfigurationProfileIdentifier: this.source.profile,
RequiredMinimumPollIntervalInSeconds: this.source.minPollSeconds,
}),
);
if (!res.InitialConfigurationToken) throw new Error("AppConfig returned no InitialConfigurationToken");
return res.InitialConfigurationToken;
}
private async poll(retried = false): Promise<Flags> {
try {
this.token ??= await this.startSession();
const res = await this.client.send(new GetLatestConfigurationCommand({ ConfigurationToken: this.token }));
this.token = res.NextPollConfigurationToken; // each token is valid for one call only
this.nextPollAt = Date.now() + (res.NextPollIntervalInSeconds ?? 60) * 1000;
const body = res.Configuration?.transformToString() ?? "";
if (body.length > 0) {
// A non-empty body means a new version; an empty one means "no change since your last token".
this.flags = JSON.parse(body) as Flags;
this.version = res.VersionLabel;
}
return this.flags;
} catch (err) {
this.token = undefined;
if (err instanceof BadRequestException && !retried) return this.poll(true); // expired or reused token
console.error("AppConfig poll failed, serving last known flags:", err);
this.nextPollAt = Date.now() + 30_000; // back off instead of calling AppConfig on every request
return Object.keys(this.flags).length ? this.flags : this.defaults;
}
}
}
Using it in a Lambda function:
// checkout-handler.ts: using FeatureFlags in a Lambda function. The instance lives outside the handler,
// so warm invocations reuse the cached flags and the session token.
import { FeatureFlags } from "./feature-flags.js";
const flags = new FeatureFlags(
{ application: "shop", environment: "prod", profile: "checkout-flags", minPollSeconds: 30 },
{ newCheckout: { enabled: false }, freeShipping: { enabled: false, threshold: 50 } }, // used if AppConfig is unreachable
);
export async function handler(event: { cartTotal: number }): Promise<{ flow: string; freeShipping: boolean }> {
const flow = (await flags.isEnabled("newCheckout")) ? "v2" : "v1";
const threshold = await flags.attribute("freeShipping", "threshold", Number.POSITIVE_INFINITY);
return { flow, freeShipping: event.cartTotal >= threshold };
}
Why it’s built this way:
- One instance per process. Created at module scope, so warm Lambda invocations and every request in a container share one session and one cache.
- Lazy polling. It calls AppConfig only when a flag is read after the interval has passed. A Lambda that sits idle makes no calls; there’s no timer keeping it busy.
- Shared in-flight call. Ten concurrent requests after the interval expires trigger one
GetLatestConfiguration, not ten. Two parallel calls with the same token would also break the one-call-per-token rule. - Safe defaults. If AppConfig can’t be reached on a cold start, the code falls back to hard-coded defaults, which should always be the old, safe behavior.
How do polling and empty responses work?
A session is a cursor. AppConfig remembers which version your token last saw, and GetLatestConfiguration returns data only when a newer deployment exists; VersionLabel is empty in that case too. The SDK gives you Configuration as a byte array with a transformToString() helper, so check its length rather than its truthiness.
RequiredMinimumPollIntervalInSeconds sets a floor on a session: with 60, the client that started it can’t call GetLatestConfiguration more often than every 60 seconds. That’s a useful guard when a bug could put all() in a loop. A token that’s reused or older than 24 hours gets BadRequestException; the client above drops the token and starts a new session once before giving up and serving cached flags.
How quickly a change reaches you depends on two things: your poll interval, and the deployment strategy. A linear strategy with 20% growth over 10 hours means most clients see the new value gradually by design. If a CloudWatch alarm fires during the deployment or its bake time, AppConfig rolls back, and your next poll returns the previous version.
Should you use the AppConfig Lambda extension instead?
In Lambda, AWS recommends the AWS AppConfig Agent Lambda extension. Added as a layer, it serves the configuration from a local cache at localhost:2772 and polls AppConfig itself, by default every 45 seconds (AWS_APPCONFIG_EXTENSION_POLL_INTERVAL_SECONDS). AWS_APPCONFIG_EXTENSION_PREFETCH_LIST loads the flags before your handler’s first invocation.
// extension-flags.ts: read the same flags through the AWS AppConfig Agent Lambda extension instead of the SDK.
// The extension caches the data and polls AppConfig itself (every 45 seconds by default).
type Flags = Record<string, { enabled: boolean } & Record<string, unknown>>;
const port = process.env.AWS_APPCONFIG_EXTENSION_HTTP_PORT ?? "2772";
const url = `http://localhost:${port}/applications/shop/environments/prod/configurations/checkout-flags`;
export async function handler(): Promise<{ flow: string }> {
const res = await fetch(url);
if (!res.ok) throw new Error(`AppConfig extension returned ${res.status}`);
const flags = (await res.json()) as Flags;
return { flow: flags.newCheckout?.enabled ? "v2" : "v1" };
}
Choose the extension if you can add a layer: no session code, and multi-variant flags work, because the agent evaluates variant rules against the context you send. Choose the SDK client if you can’t add layers, if the code runs outside Lambda and ECS or EKS without the agent, or if you want flags in scripts and tests. Either way, keep the SDK bundle small; reducing AWS SDK v3 bundle size in Lambda explains how.
What does reading AppConfig feature flags cost?
As of September 2026, the AWS Price List for AWS Systems Manager, which includes AppConfig (published 11 September 2026), gives these us-east-1 rates:
| Charge | Price |
|---|---|
| Configuration request (API call) | $0.0000002 per request ($0.20 per million) |
| Configuration received | $0.0008 per configuration received |
Worked example with caching: 40 containers polling every 60 seconds for 30 days make 40 × 1,440 × 30 = 1,728,000 requests, or $0.35. With 25 deployments that month, each container receives about 25 new configurations: 1,000 × $0.0008 = $0.80. Total: about $1.15.
Without caching: a Lambda function that starts a new session on every invocation receives the full configuration every time. At 10 million invocations that’s at least 10 million requests ($2.00) and 10 million configurations received, which would be billed at 10,000,000 × $0.0008 = $8,000. That gap is the whole reason the client above lives at module scope. To see what AppConfig actually costs you, break down last month’s AWS bill by service.
Permissions needed
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadCheckoutFlags",
"Effect": "Allow",
"Action": [
"appconfig:StartConfigurationSession",
"appconfig:GetLatestConfiguration"
],
"Resource": "arn:aws:appconfig:us-east-1:123456789012:application/abc1234/environment/def5678/configuration/ghi9012"
}
]
}
The configuration resource ARN uses the application, environment and profile IDs, even when your code passes names. The extension needs the same two actions on the function’s execution role. For deriving actions from code, see finding the IAM actions in AWS SDK for JavaScript code, or paste the module into the IAM policy generator for TypeScript code.
Troubleshooting and common mistakes
BadRequestExceptiononGetLatestConfiguration. A token was reused or is more than 24 hours old, often because two requests raced with the same token. Share one in-flight call as the client does.ResourceNotFoundException. A typo in the application, environment or profile name, or the wrong Region.- Flags are always empty. Nothing has been deployed to that environment yet, or your code treats the empty “no change” body as the new configuration.
- Access denied. The policy uses names instead of IDs in the ARN. Then see troubleshooting AWS IAM access denied errors.
ThrottlingException. Clients poll faster than needed. HonorNextPollIntervalInSecondsand cache.
How do you test feature flag code?
// feature-flags.test.ts: npx tsx test/feature-flags.test.ts
import assert from "node:assert/strict";
import { mockClient } from "aws-sdk-client-mock";
import { Uint8ArrayBlobAdapter } from "@smithy/util-stream";
import {
AppConfigDataClient,
StartConfigurationSessionCommand,
GetLatestConfigurationCommand,
BadRequestException,
} from "@aws-sdk/client-appconfigdata";
import { FeatureFlags } from "../src/feature-flags.ts";
const mock = mockClient(AppConfigDataClient);
const body = (s: string) => Uint8ArrayBlobAdapter.fromString(s);
let t = 0;
const realNow = Date.now;
Date.now = () => t;
mock.on(StartConfigurationSessionCommand).resolves({ InitialConfigurationToken: "tok-0" });
mock
.on(GetLatestConfigurationCommand)
.resolvesOnce({
Configuration: body('{"newCheckout":{"enabled":true},"freeShipping":{"enabled":true,"threshold":75}}'),
NextPollConfigurationToken: "tok-1",
NextPollIntervalInSeconds: 30,
VersionLabel: "v7",
})
.resolvesOnce({ Configuration: body(""), NextPollConfigurationToken: "tok-2", NextPollIntervalInSeconds: 30 })
.rejectsOnce(new BadRequestException({ message: "expired", $metadata: {} }))
.resolvesOnce({ Configuration: body('{"newCheckout":{"enabled":false}}'), NextPollConfigurationToken: "tok-4", NextPollIntervalInSeconds: 30 });
const flags = new FeatureFlags({ application: "shop", environment: "prod", profile: "checkout-flags" }, { newCheckout: { enabled: false } });
const [a, b] = await Promise.all([flags.isEnabled("newCheckout"), flags.isEnabled("newCheckout")]);
assert.equal(a && b, true);
assert.equal(mock.commandCalls(GetLatestConfigurationCommand).length, 1); // concurrent callers shared one call
assert.equal(await flags.attribute("freeShipping", "threshold", 0), 75);
assert.equal(flags.version, "v7");
t = 31_000; // interval passed: empty body keeps the flags
assert.equal(await flags.isEnabled("newCheckout"), true);
assert.equal(mock.commandCalls(GetLatestConfigurationCommand)[1].args[0].input.ConfigurationToken, "tok-1");
t = 62_000; // token rejected: new session, then new data
assert.equal(await flags.isEnabled("newCheckout"), false);
assert.equal(mock.commandCalls(StartConfigurationSessionCommand).length, 2);
Date.now = realNow;
console.log("feature flag tests passed; calls:", mock.commandCalls(GetLatestConfigurationCommand).length);
Uint8ArrayBlobAdapter from @smithy/util-stream builds the same body type the SDK returns. The guide to mocking AWS SDK v3 clients in unit tests has the Jest and Vitest versions.
Limits: what this client doesn’t do
- No multi-variant evaluation. Variant rules are evaluated by AppConfig Agent; this client reads basic flags.
- Changes arrive on your poll interval, not instantly. For an emergency kill switch, a short minimum interval on a few critical flags is cheaper than polling everything fast.
- Not for secrets. Flag values are plain configuration; use Secrets Manager for credentials.
- Old flags don’t clean themselves up. Pete Hodgson’s article on feature toggles describes treating them as inventory with a carrying cost, and suggests expiry dates for release toggles.
Frequently asked questions
How do I get AppConfig feature flags in Node.js?
Use @aws-sdk/client-appconfigdata: start a session with StartConfigurationSessionCommand, then call GetLatestConfigurationCommand with the token and parse the JSON. In Lambda, the AppConfig Agent extension at localhost:2772 is the alternative.
Why does GetLatestConfiguration return an empty response?
Your token has already seen the latest deployed version, so there’s nothing new to send. Keep using the configuration you parsed earlier.
Can I use GetConfiguration for feature flags?
No. GetConfiguration is deprecated and can’t return feature flag data. Use GetLatestConfiguration.
How often should I poll AppConfig?
Follow NextPollIntervalInSeconds (60 seconds by default). The minimum you can require per session is 15 seconds.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud