Our service is built with React and NestJS, all in TypeScript, and the backend runs on ECS on Fargate.
Until recently we handled small adjustments—cutting the session-timeout during QA, rolling out a beta feature to a subset of users—by overriding environment variables and redeploying. Each redeploy triggered the whole CI/CD pipeline and kept us waiting three to five minutes. That delay soon felt heavier than the change itself. Three minutes felt trivial at first, but during a hot-fix it was an eternity.
Inspired by Kaminashi’s article, we adopted AWS AppConfig so we can flip feature flags straight from the console. It looked easy, but I still managed to hit a few snags—here’s the full write-up.
Why bring in feature flags?
- During QA we sometimes need a much shorter session timeout.
- Beta features should appear only for internal users at first.
- In an emergency we want the option to roll back without touching code.
With AppConfig each of these tweaks is now a single click.
Overall architecture
An AppConfig Agent runs as a sidecar in every ECS task and polls for configuration updates (the default interval is 45 seconds). The application itself just calls localhost:2772
; all SDK calls are hidden inside the sidecar, so the main container has zero AWS-SDK dependencies.
See What is AWS AppConfig Agent? in the official docs.
Setting up AppConfig
1. Create an application and a configuration profile
- Application:
feature-flags
- Configuration profile:
feature-flags-config
2. Add a Basic Flag
For a simple on/off switch, create a Basic Flag—for example isDebug
.
3. Add a Multi-Variant Flag
When the value depends on user attributes (in our case sessionId
), choose a Multi-Variant Flag. The rule builder in the side panel lets you describe the conditions freely.
4. Define environments
Because flag values differ by environment, create dev
, stg
, and prod
on the Environments tab.
Here is the form for dev
:
5. Deploy the configuration
Pick the environment, hosted configuration version, and a deployment strategy, then press "Start Deployment".
A canary strategy such as Canary10Percent20minutes
works like this:
- At 0 minutes → apply the new config to 10 percent of traffic.
- Bake for 10 minutes → monitor for errors; roll back if needed.
- Over the next 10 minutes → ramp from 10 to 100 percent (for example 30 → 60 → 100).
- At 20 minutes → all traffic uses the new config.
Adding the AppConfig Agent to an ECS task definition
Following the official docs, append this to the task definition:
{ "name": "aws-appconfig-agent", "image": "public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x", "cpu": 128, "memoryReservation": 256, "essential": true, "environment": [ { "name": "PREFETCH_LIST", "value": "/applications/feature-flags/environments/dev/configurations/feature-flags-config" } ], "portMappings": [ { "containerPort": 2772, "protocol": "tcp" } ] }
Make sure the sum of CPU and memory for the main container and the sidecar fits the task limit.
PREFETCH_LIST
tells the agent which paths to fetch in advance.
If you need a different polling interval, set POLL_INTERVAL
(the default is 45 seconds).
Other options are listed in the official docs.
Loading feature flags in the NestJS application
Service implementation (excerpt)
We created a FeatureFlagsService
that retrieves feature flags.
@Injectable() export class FeatureFlagsService { async evaluateFlag<T = boolean>( flagName: string, context: Record<string, string | number | boolean> = {}, defaultValue: T, ): Promise<T> { const environment = process.env.ENV || 'dev' const isDevelopment = environment !== 'prod' try { // --- Local environment: override with an env var --- if (environment === 'local') { const envFlagName = `FEATURE_FLAG_${flagName .replace(/([A-Z])/g, '_$1') .toUpperCase() .replace(/^_/, '')}` const envValue = process.env[envFlagName] if (envValue !== undefined) { return this.parseEnvValue<T>(envValue, defaultValue) } return defaultValue } // --- Other environments: fetch through the AppConfig agent --- const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) const headers: Record<string, string> = {} if (Object.keys(context).length > 0) { headers.Context = Object.entries(context) .map(([key, value]) => `${key}=${String(value)}`) .join(',') } const response = await fetch( `http://localhost:2772/applications/feature-flags/environments/${environment}/configurations/feature-flags-config`, { signal: controller.signal, headers }, ) clearTimeout(timeoutId) if (!response.ok) { this.warnDevelopment( isDevelopment, `[FeatureFlags] AppConfig response not ok: ${response.status} for flag ${flagName}`, ) return defaultValue } const config = await response.json() this.logDevelopment( isDevelopment, `[FeatureFlags] AppConfig response for ${flagName} with context ${JSON.stringify(context)}:`, JSON.stringify(config, null, 2), ) if (flagName.toLowerCase() in config || flagName in config) { const flagKey = flagName in config ? flagName : flagName.toLowerCase() const flagValue = config[flagKey] this.logDevelopment( isDevelopment, `[FeatureFlags] Flag value for ${flagName}:`, JSON.stringify(flagValue, null, 2), ) if (typeof flagValue === 'object' && flagValue !== null && 'enabled' in flagValue) { this.logDevelopment(isDevelopment, `[FeatureFlags] flagValue.enabled:`, flagValue.enabled) return flagValue.enabled as T } } this.warnDevelopment(isDevelopment, `Unexpected AppConfig response format for ${flagName}`) return defaultValue } catch (error) { this.warnDevelopment(isDevelopment, `Feature flag evaluation error for ${flagName}:`, error) return defaultValue } } /** Convert env-var strings to the appropriate type */ private parseEnvValue<T>(envValue: string, defaultValue: T): T { if (typeof defaultValue === 'boolean') { return (envValue.toLowerCase() === 'true') as T } if (typeof defaultValue === 'number') { const parsed = Number(envValue) return (isNaN(parsed) ? defaultValue : parsed) as T } return envValue as T } /** Log only in non-production environments */ private logDevelopment(isDevelopment: boolean, message: string, ...args: unknown[]): void { if (isDevelopment) console.log(message, ...args) } /** Warn only in non-production environments */ private warnDevelopment(isDevelopment: boolean, message: string, ...args: unknown[]): void { if (isDevelopment) console.warn(message, ...args) } }
Local environment
Because there is no sidecar container locally, you can override flags with
FEATURE_FLAG_<FLAG_NAME>
environment variables.
if (environment === 'local') { const envFlagName = `FEATURE_FLAG_${flagName .replace(/([A-Z])/g, '_$1') .toUpperCase() .replace(/^_/, '')}` const envValue = process.env[envFlagName] if (envValue !== undefined) { return this.parseEnvValue<T>(envValue, defaultValue) } return defaultValue }
Sending context information
To enable rules such as “turn the flag on only for a specific sessionId
,”
context information must be passed in the Context
header.
const headers: Record<string, string> = {} if (Object.keys(context).length > 0) { const contextHeader = Object.entries(context) .map(([key, value]) => `${key}=${String(value)}`) .join(',') headers.Context = contextHeader }
Example:
await featureFlagsService.evaluateFlag('isBetaUI', { sessionId: 1 }, false) // → Header includes: Context: sessionId=1
Switching the UI on the frontend
Controller
Reading flags on the backend is useful, but when you want to show a new UI only to certain users, the frontend also needs to know the flag state.
We added a FeatureFlagsController
that returns multiple flags at once, so React can call it directly.
@Controller('v1/feature-flags') export class FeatureFlagsController { constructor(private readonly featureFlagsService: FeatureFlagsService) {} /** Get the state of multiple feature flags */ @Get() async getFeatureFlags( @Query() query: GetFeatureFlagsDto, ): Promise<{ flags: Record<string, boolean> }> { const flagNames = query.flags ? query.flags.split(',') : [] const flags: Record<string, boolean> = {} const context: Record<string, string | number | boolean> = {} if (query.siteId !== undefined) context.siteId = String(query.siteId) for (const name of flagNames) { flags[name] = await this.featureFlagsService.evaluateFlag(name, context, false) } return { flags } } /** Get the state of a single feature flag */ @Get(':flagName') async getFeatureFlag( @Param('flagName') flagName: string, @Query() query: GetFeatureFlagDto, ): Promise<{ flagName: string; enabled: boolean }> { const defaultValue = query.defaultValue === 'true' const context: Record<string, string | number | boolean> = {} if (query.siteId !== undefined) context.siteId = String(query.siteId) const enabled = await this.featureFlagsService.evaluateFlag(flagName, context, defaultValue) return { flagName, enabled } } }
TanStack Query custom hooks
On the React side we prepared lightweight hooks with TanStack Query.
/** Thin wrapper for a single flag */ export const useFeatureFlag = ( flagName: string, ctx: Partial<FeatureFlagContext> = {}, ) => { const { data, isLoading, error } = useFeatureFlags([flagName], ctx) return { enabled: data?.flags[flagName] ?? false, // default to false while loading loading: isLoading, error, } } /** Fetch multiple flags at once */ export const useFeatureFlags = ( flagNames: string[], ctx: Partial<FeatureFlagContext> = {}, ) => useQuery({ queryKey: ['feature-flags', [...flagNames].sort(), ctx], queryFn: () => fetchFeatureFlags(flagNames, ctx), enabled: flagNames.length > 0, staleTime: 5 * 60 * 1000, retry: false, }) /** HTTP call separated for clarity */ const fetchFeatureFlags = async ( flagNames: string[], ctx: Partial<FeatureFlagContext>, ): Promise<GetFeatureFlagsResponseDto> => { const res = await apiClient.get<GetFeatureFlagsResponseDto>('v1/feature-flags', { params: { flags: flagNames.join(','), ...ctx }, }) if (res instanceof Failure) throw res return res.value }
Conclusion
With AppConfig in place, we no longer need to redeploy just to flip a flag, so the wait time has dropped to zero and the stress around releases has eased considerably.
At the same time, we have to watch for deployment mistakes—especially when multi-variant flag rules differ by environment. A slip could push a dev rule into prod.
Defining clear operational guidelines for these cases is the next item on our agenda.
Top comments (0)