Skip to main content

Flags Without a Dashboard

Everything on this page is verified against lib/featureFlags.ts, pages/api/feature-flags.ts, stores/featureFlagStore.ts and context/ConfigContext.tsx.

Sooner or later you'll want to ship a feature dark, and you'll go looking for the flag dashboard. Save yourself the search: there isn't one. No LaunchDarkly, no Azure App Configuration client in the browser — the entire system is one TypeScript interface, one BFF route, one Zustand store, and four hooks. Flags come from the backend's own /FeatureFlags endpoint, and everything else is merging. Which means the only thing you actually need to learn is the merge order — and it fits in nine lines.

The four moving parts

PieceFileRole
FeatureFlagNameslib/featureFlags.tsThe closed set of known flag keys. 28 optional booleans. Adding a flag means adding a key here — keyof FeatureFlagNames is what makes the hooks type-safe
defaultFlagslib/featureFlags.tsThe floor. Only five keys, all explicitly false
applyRules(flags, env, isLocalhost)lib/featureFlags.tsThe ceiling. Local-development overrides, applied last
/api/feature-flagspages/api/feature-flags.tsDoes the merge, server-side, once per page load

Precedence, exactly

The whole resolution is nine lines in the BFF route:

const rawFlags: FeatureFlagResponse[] =
(await callBackend<FeatureFlagResponse[]>('/FeatureFlags', { ... })) || [];

const externalFlags: FeatureFlagNames = {};
rawFlags.forEach(flag => {
externalFlags[flag.flagName as keyof FeatureFlagNames] = flag.isEnabled;
});

let flags = { ...defaultFlags, ...externalFlags };

const host = req.headers.host || '';
const env = getEnvironment(host);
const isLocalhost = host.includes('localhost') || host.includes('192.168.');

flags = applyRules(flags, env, isLocalhost);

So, from lowest to highest:

  1. defaultFlags — the safety floor. Five keys today, every one of them false: enable-camera-drag-pan, enable-pano-degree-offset, enable-peakfinder, enable-pano-freshness-badge, enable-camera-area-filter. Each has a comment in the source explaining why it is off; they are all staged rollouts the backend is expected to turn on. The remaining 23 keys in FeatureFlagNames have no default at all — absent from the response means undefined, which every consumer treats as off.
  2. Backend flags{ ...defaultFlags, ...externalFlags }. A backend flag beats a default, including when the backend says false.
  3. isLocalhost — the only rule applyRules actually implements, and it wins over everything. On a localhost or 192.168. host it force-sets seven flags to true: enable-debug, enable-offline-button, enable-ai-detection-sound, enable-pano-degree-offset, enable-peakfinder, enable-pano-freshness-badge, enable-camera-area-filter. A backend false for any of those seven is discarded locally.
env is accepted and never used

applyRules(flags, env, isLocalhost) takes an env parameter, and the route computes it with getEnvironment(host) — but the function body never reads it. There are no environment-scoped overrides. dev, uat and prd all get exactly what the backend sends, plus nothing. If you are looking for "why isn't this flag on in dev", the answer is the backend's /FeatureFlags response, not this file. The parameter is a placeholder for a rule set that does not exist yet; getEnvironment itself works and is unit-tested, it is simply not wired to anything.

applyRules also mutates the object it is handed and returns the same reference. That is harmless where it is called — both call sites pass a freshly spread object — but do not assume it is pure.

From the route to a component

ConfigProvider fetches /api/env and /api/feature-flags in parallel on mount and renders a <Loader /> until both settle:

const [configResponse, featureFlagsResponse] = await Promise.all([
fetch('/api/env'),
fetch('/api/feature-flags'),
]);

A non-OK feature-flags response is logged and swallowed — only /api/env failing throws. The provider sits above AuthGuard in app/layout.tsx, so no component ever renders before the flags are in the store; there is no "flag flicker" to design around.

useFeatureFlagStore is persist(devtools(...)) — the flags are written to localStorage under the key feature-flags-storage, and the initial state is { ...defaultFlags } before rehydration. setFlags merges rather than replaces:

setFlags: (flags) => set({ flags: { ...get().flags, ...flags }, isLoaded: true }),

Two consequences worth internalising:

  • Persisted flags outlive a deployment. A returning user starts on last session's values until /api/feature-flags resolves. Because the route always returns the full merged set, every key it knows about gets overwritten — but a flag key the route stops returning keeps its stale persisted value forever, until the user clears site data or reset() runs.
  • isLoaded is set by setFlags, not by rehydration. It means "the network response landed", so it distinguishes real flags from persisted/default ones.

Four read hooks, all in featureFlagStore.ts:

Hook / methodReturns
useIsFeatureEnabled(name)boolean!!flags[name], the one to use for gating
useFeatureFlag<T>(name)The raw value, undefined if unset — use when "off" and "unset" differ
useAllFeatureFlags()The whole object
useFeatureFlagStore.getState().getFlag(name)Non-reactive read, for use outside React

enable-all-features-connected-to-signalr

This is the flag that matters most, because it is not a UI toggle — it is the master switch for the entire real-time subsystem.

The gate is the first statement in signalRStore.initializeConnection:

const isSignalREnabled = useFeatureFlagStore
.getState()
.getFlag('enable-all-features-connected-to-signalr');

if (!isSignalREnabled) return;

It returns before HubConnectionBuilder runs, so with the flag off there is no connection, no JoinGroup, and none of the five broadcast handlers are ever registered. Every downstream consumer of live data — new camera images, panoramas, activity-log entries, the lease countdown, AI detections — is dark. The fan-out those handlers drive is documented in deep-dives/signalr-realtime-architecture.

Because the app cannot simply stop working, three surfaces read the same flag and switch to polling or to a static presentation:

ConsumerWith the flag onWith it off
TileViewPageJoins the camera group for primaryCamera and drives loading state off SignalRif (!isSignalREnabled || !connection) return; — no group join; setIsLoading(false) is driven by the TanStack Query callbacks instead
PanoImagerefetchInterval: false — the query never polls; mode="live" on the freshness badgerefetchInterval: 10000 — polls every 10 s; mode="polling"
CameraImageWrapperRenders the SignalR-fed image branchRenders the polled branch (both branches additionally require offlineTier < OfflineTier.OFFLINE_7D)
Lease countdownReceiveCurrentConfigBroadcast drives secondsRemainingInLeaseModeThe countdown never leaves 0; PTZ still works over plain HTTP. See deep-dives/camera-lease-concurrency
This flag has no entry in defaultFlags

It is not one of the five defaulted keys, so if the backend does not return it — or if /FeatureFlags fails and the fallback path runs — it resolves to undefined, which is falsy, and the entire SignalR subsystem stays off. That is the safe direction, but it means a backend blip presents to operators as "live updates stopped working" rather than as an error.

Adding a flag

  1. Add the key to FeatureFlagNames in lib/featureFlags.ts. Without this, getFlag and useIsFeatureEnabled will not type-check against it.
  2. Decide whether it needs a defaultFlags entry. It does if the feature hides or removes something — the comment on enable-camera-area-filter is the reference case: a filter that hides cameras must be off until the backend explicitly enables it. A purely additive feature can be left undefaulted and rely on undefined being falsy.
  3. If local development should always see it, add a line to applyRules under the isLocalhost branch — and remember this overrides the backend, so do not put anything there whose "off" behavior you need to test locally.
  4. Register the flag on the backend so /FeatureFlags returns it. Until then, only the default and the localhost rule apply.
  5. Gate the UI with useIsFeatureEnabled('your-flag').

lib/featureFlags.test.ts covers the precedence rules directly — including applyRules({ 'enable-360': false }, 'dev', true) proving that backend values survive the localhost pass for keys the localhost rules do not touch. Extend it when you add a rule.

Debugging checklist

  • A flag is on locally but off in dev/uat/prd — it is almost certainly one of the seven isLocalhost overrides. Compare against the list above before assuming a deployment problem.
  • A flag is off everywhere including localhost — check the raw response: GET /api/feature-flags in the network tab returns the fully merged object, which is the single source of truth for what the browser was told.
  • The value in the app disagrees with /api/feature-flags — you are looking at a persisted value in localStorage under feature-flags-storage, or at a flag key that the route no longer returns. Clear site data to confirm.
  • Everything real-time stopped — check enable-all-features-connected-to-signalr first, then whether /FeatureFlags itself is failing (the fallback path logs Error fetching feature flags, using fallback and still returns 200).
  • A -dev deployment is not getting the localhost overrides — that is correct behavior; isLocalhost and getEnvironment(host) === 'dev' are not the same test.