A Simple Feature Flag System with Runtime Config + @defer

December 26, 2024 Updated: December 26, 2024 Rens Jaspers Angular TypeScript Webdev

Feature flags are essential for fast releases: without them, one not-quite-ready feature can block other changes from shipping.

In my previous post, I showed how to load a runtime JSON config before Angular bootstraps, using provideAppInitializer.

In this article, we’ll build on that idea and add feature flags:

  • Put feature flags in the same runtime config JSON
  • Keep usage type-safe in TypeScript (no typo bugs)
  • Use flags synchronously anywhere — including Angular templates with @defer (when ...)

Important: feature flags are not security

Feature flags on the frontend are about UI/UX and behavior toggles, not access control.

  • Users can inspect and modify everything that runs in the browser.
  • Never rely on a frontend feature flag to protect sensitive data or privileged actions.

If something must be protected, enforce it on the server.

The config file (now with feature flags)

Just like in the runtime-config post, we’ll keep the config in public/ and load it from the same origin.

For example:

  • public/environment/public-environment-config.json → served as /environment/public-environment-config.json

Add a featureFlags object:

{
  "prod": true,
  "apiUrl": "https://api-prod.example.com",
  "featureFlags": {
    "newLogo": false,
    "betaSearch": true
  }
}

Step 1 — Define types for your feature flags

The key idea is simple:

  • you define the set of flags once in TypeScript
  • your app only accepts keys from that set
// feature-flags.ts
export type FeatureFlags = {
  newLogo: boolean;
  betaSearch: boolean;
};

Now, when you call isEnabled('newLogo'), TypeScript can verify the key. If you typo it (like 'newLoogo'), you get a compile error.

Step 2 — Extend your RuntimeConfig type

Update the runtime config type from the previous article to include the flags:

// runtime-config.service.ts
import { Injectable } from "@angular/core";
import type { FeatureFlags } from "./feature-flags";

export type RuntimeConfig = {
  prod: boolean;
  apiUrl: string;
  featureFlags: FeatureFlags;
};

@Injectable({ providedIn: "root" })
export class RuntimeConfigService {
  private readonly configUrl = "/environment/public-environment-config.json";
  config!: RuntimeConfig;

  async load(): Promise<void> {
    const response = await fetch(this.configUrl, { cache: "no-store" });

    if (!response.ok) {
      throw new Error(`Config load failed: ${response.status} ${response.statusText} (${this.configUrl})`);
    }

    this.config = (await response.json()) as RuntimeConfig;
  }
}

Quick note about validation (skipping it here)

In this post, we’re keeping it simple and using a type assertion:

this.config = (await response.json()) as RuntimeConfig;

That gives you great type safety inside your app code, but it does not guarantee the JSON is correct at runtime.

In production, it’s a good idea to validate the JSON and fail fast with a clear error (or fallback to defaults).

You could do that with a small custom validator, or a schema library like Zod / Valibot. We’ll skip it here to keep the post focused.

Step 3 — Create a FeatureFlagsService

Now we’ll add a small service that wraps access to the flags.

The important part is the signature:

  • key is keyof FeatureFlags
  • so you can’t pass unknown flag names
// feature-flags.service.ts
import { Injectable, inject } from "@angular/core";

import type { FeatureFlags } from "./feature-flags";
import { RuntimeConfigService } from "./runtime-config.service";

@Injectable({ providedIn: "root" })
export class FeatureFlagsService {
  private readonly runtimeConfig = inject(RuntimeConfigService);

  isEnabled<K extends keyof FeatureFlags>(key: K): boolean {
    return this.runtimeConfig.config.featureFlags[key];
  }
}

Because the runtime config is loaded via provideAppInitializer (from the previous article), this is safe:

  • the flags are already available
  • access is synchronous

Step 4 — Use a feature flag in an Angular template

In a component, inject the service:

import { Component, inject } from "@angular/core";
import { FeatureFlagsService } from "./feature-flags.service";

@Component({
  selector: "app-some-page",
  templateUrl: "./some-page.component.html",
})
export class SomePageComponent {
  private readonly flags = inject(FeatureFlagsService);

  readonly showNewLogo = this.flags.isEnabled("newLogo");
}

Now you can also use them in templates.

The nice part: @defer (when ...) lets you skip loading a disabled feature’s component entirely.

@defer (when showNewLogo) {
  <app-new-logo />
}

@defer (when !showNewLogo) {
  <app-old-logo />
}

If showNewLogo is true, only the new logo will be loaded over the network.

Note: in development with HMR enabled, @defer dependencies can be fetched eagerly. Rendering still respects the when conditions, but to verify real network loading behavior, disable HMR.

“Build once, deploy everywhere” — toggling without rebuilding

The best part of this setup is how it works in practice:

  • The same frontend build is used everywhere.
  • You can turn features on or off by editing /environment/public-environment-config.json on the server.

How it works:

  • Deploy the code with the feature turned off.
  • When it’s ready, change the flag to true.
  • Reload the app and the new behavior is live.