Skip to main content

Feature Control Trimming

Table of Contents

  1. Overview
  2. Basic Principle
  3. Runtime Flow
  4. Trim Conditions
  5. Static Route Key Generation
  6. Development Workflow
  7. Build and Deployment Workflow
  8. Troubleshooting

Overview

Feature Control Trimming reduces the feature-control payload used by SSR pages. Instead of keeping the full feature-control map returned by the backend, SSR can keep only the feature-control keys that the current route is known to use.

The goal is to reduce the amount of feature-control data serialized into the page and sent to the client, while keeping runtime behavior correct for routes whose feature-control usage can be proven statically.

The implementation has three parts:

  • A static analyzer that generates one feature-control-route-keys.json file per service.
  • SSR normalization logic in getFeatureControl.ts that uses the generated JSON to trim feature-control data.
  • Build and CI checks that keep generated JSON committed, fresh, and available in production server output.

Basic Principle

Each service owns a generated file:

packages/<domain>/app-desktop/feature-control-route-keys.json
packages/<domain>/app-mobile/feature-control-route-keys.json

The file maps a Next.js route to the feature-control keys used by that route:

{
"service": "webfpr-desktop",
"routes": {
"/flight": ["flight-homepage-banner", "flight-search-form"]
},
"routeRegexes": {
"/flight": ["^flight-campaign-.+$"]
},
"untrimmableRoutes": {}
}

During SSR, getFeatureControl fetches the full feature-control map as usual. Before returning the normalized map, it checks the current route from ctx.asPath. If the route has generated static keys and trimming is enabled for the current service, keys that are not listed for that route are removed. Each route entry directly contains the complete exact key set needed by that route.

Regex entries are used for cases where the analyzer can prove the static shape of a feature-control key but cannot enumerate every possible concrete value, such as a static prefix with a dynamic suffix.

Runtime Flow

The runtime flow is:

  1. getFeatureControlMap(ctx) fetches feature-control data from the backend or fallback.
  2. normalizeFeatures(features, ctx) normalizes enabled values and checks whether route-level trimming can run.
  3. Runtime service name is read from public runtime config appName.
  4. Route-key JSON is read from one of the supported file locations.
  5. The current route is matched with ctx.asPath.
  6. If the route is trimmable, exact route keys and regex-matched keys are kept.
  7. Metadata keys are attached to the trimmed result so client-side FeatureControl can suppress warnings for keys known to belong to the current route static set.

Route-key JSON lookup order:

  1. .next/server/feature-control-route-keys.json under the current working directory.
  2. feature-control-route-keys.json under the current working directory.

The .next/server location is checked first because production next build emits the JSON beside the server bundle. The app-root file is kept as a fallback for local development, where the committed generated JSON lives beside the service source files.

Trim Conditions

Feature Control Trimming is intentionally opt-in and guarded. A request is trimmed only when all of these conditions are true:

  1. The code is running in SSR.

    ctx.req must exist. Client-side normalization does not read route-key JSON.

  2. The current service can be resolved.

    Runtime config appName must be available and is normalized from @traveloka/webxxx-desktop to webxxx-desktop.

  3. The fc-trim feature control enables trimming for the current service.

    The feature-control response must contain:

    {
    "fc-trim": {
    "enabled": true,
    "properties": {
    "service": ["webfpr-desktop"]
    }
    }
    }

    enabled can be boolean true or string "true". The properties.service value must be a list and must include the current service.

  4. The route is allowed by featureControlTrimScope.

    The scope file is packages/core/feature-control/featureControlTrimScope.ts.

    Its key is service name and its value is the route list allowed to trim. If a service is not present in this map, no routes for that service can trim. If a service is present, only the listed routes can trim.

  5. A route-key JSON file exists and matches the current service.

    If the JSON file has a service field and it does not match the current runtime service, it is ignored.

  6. The current ctx.asPath has static keys or static key regexes.

    Route matching is exact against ctx.asPath.

  7. The current route is not marked as untrimmable.

    Routes with unresolved dynamic feature-control usage are written into untrimmableRoutes. Those routes keep the full feature-control map.

If any condition fails, normalizeFeatures falls back to the full normalized feature-control map.

Static Route Key Generation

The generator command is:

pnpm generate-feature-control-route-keys-json

This scans all webxxx-desktop and webxxx-mobile services with a pages directory.

Global generation post-processes analyzer output by filtering each service-owned feature-control-route-keys.json to the routes listed in featureControlTrimScope.ts. Exact keys that appear on many or all routes stay inline in every route that needs them.

To generate for one service:

pnpm generate-feature-control-route-keys-json webfpr-desktop

The command runs ci-utils/feature-control-route-analyzer/index.js with --all-routes. For each service, the analyzer:

  1. Collects Page Router routes from pages.
  2. Resolves each route page and pages/_app.
  3. Builds the reachable source graph from static imports, dynamic imports, and local require calls.
  4. Detects feature-control usage from hooks, direct feature-control object reads, wrappers, JSX component props, map wrappers, and computed access helpers.
  5. Writes runtime route keys to feature-control-route-keys.json.
  6. Writes dynamic usage diagnostics under tmp/feature-control-route-analyzer/<service>.

The analyzer is conservative. When it cannot prove the accessed subset of feature-control keys, it records dynamic usage. Dynamic usage means the route should not be trimmed until the usage is made analyzable or the analyzer is enhanced.

Examples of supported static usage include:

useFeatureControl('static-key');
useFeatureControl(FEATURE_KEY);
useFeatureControl(FeatureKeyMap.search);
featureControl.get('static-key');
featureControl.staticKey;
featureControl['static-key'];

The analyzer also handles common wrapper patterns:

function useExperimentKey(key: string) {
return useFeatureControl(key);
}

useExperimentKey('checkout-experiment');

And component prop forwarding:

function FeatureGate({ fcKey }: { fcKey: string }) {
useFeatureControl(fcKey);
return null;
}

<FeatureGate fcKey="checkout-experiment" />;

Examples of dynamic usage include:

useFeatureControl(featureKeyFromRuntime);
Object.keys(featureControl);
const copy = { ...featureControl };
for (const key in featureControl) {
// ...
}

Development Workflow

When changing feature-control usage in a service:

  1. If the change involves feature controls in head-api, build head-api before running the generator.

    pnpm --filter @traveloka/core build:headAPI
  2. Run the generator for the affected service.

    pnpm generate-feature-control-route-keys-json webfpr-desktop
  3. Review the generated JSON diff and commit the updated service-owned feature-control-route-keys.json.

  4. Inspect dynamic usage output if the analyzer exits with a non-zero status.

    Dynamic usage output is written under:

    tmp/feature-control-route-analyzer/<service>/<service>.feature-control-dynamic-usages.json
  5. If a route has legitimate dynamic feature-control usage, it will be marked untrimmable and will keep the full feature-control map.

  6. To enable trimming for a service, add the service to featureControlTrimScope.ts and list only the routes that should be allowed to trim.

  7. Enable the fc-trim feature control for the target service through feature-control configuration.

Use useDeferredFeatureControl when a feature-control key is required by a client-only or dynamically rendered subtree but needs to be marked as used by the route. This prevents the key from being absent after SSR trimming.

Build and Deployment Workflow

CI runs the feature-control route-key check before building a service.

The workflow does the following:

  1. Checks that the service has a committed feature-control-route-keys.json.

    If the file is missing, CI fails and asks the engineer to run:

    pnpm generate-feature-control-route-keys-json <service>
  2. Skips analyzer execution only when the service package has no pages directory.

  3. Runs the analyzer into a temporary directory:

    tmp/feature-control-route-analyzer/<service>
  4. Compares the generated runtime JSON with the committed service JSON.

    If they differ, CI fails and asks the engineer to regenerate and commit the JSON locally.

  5. Prints dynamic usage diagnostics.

    If the analyzer exits non-zero because dynamic usage exists, CI reports that ci-utils/feature-control-route-analyzer has dynamic scenarios it cannot statically handle yet and prints the route/file/reason diagnostics.

Run the full generator when validating CI-wide route-key drift across every scoped service.

During next build, FeatureControlRouteKeysPlugin emits the service-owned JSON into the server build output as:

.next/server/feature-control-route-keys.json

This production copy step is required because production server runtime resolves files relative to the built server layout, not necessarily the service source directory.

Route-key generation is intentionally not fully automated as part of the build. The analyzer is still experimental, and engineers should review the generated JSON before it is committed. This keeps route-key changes visible and prevents silent analyzer failures, such as an empty generated key list, from removing required feature-control keys in production.

Troubleshooting

The route is not trimmed

Check these items:

  • The request is SSR and has ctx.req.
  • fc-trim.enabled is true.
  • fc-trim.properties.service includes the current service name.
  • The service exists in featureControlTrimScope.ts, and the route is explicitly listed there.
  • feature-control-route-keys.json exists in the service folder or server output.
  • The JSON service field matches the current runtime service.
  • The current ctx.asPath exactly matches a route key in the JSON.
  • The route is not listed in untrimmableRoutes.

CI says the route-key JSON is missing

Run:

pnpm generate-feature-control-route-keys-json <service>

Then commit the generated <service>/feature-control-route-keys.json.

CI says the committed route-key JSON is out of date

This means the committed local feature-control-route-keys.json differs from the JSON generated by GitHub CI for the same service.

The CI log prints a unified diff between:

  • the committed service JSON
  • the temporary JSON generated by GitHub CI under tmp/feature-control-route-analyzer/<service>

In most cases, this is caused by recent changes in the PR itself, and merging or rebasing master is not required. GitHub CI already tests the PR against the latest base branch, so regenerate the service JSON from the current branch first.

To fix it:

  1. Construct the affected service locally.

  2. Regenerate the file for the affected service.

    pnpm generate-feature-control-route-keys-json <service>
  3. Review the generated JSON diff and commit the updated service-owned feature-control-route-keys.json.

Merge or rebase master only when the PR overlaps with recent shared-code updates. For example, if master updates a shared component to use a new feature-control key and the PR adds a route that uses that shared component, the local generator cannot see the new key until the branch includes the latest shared-code change.

CI reports dynamic feature-control usage

If CI reports dynamic feature-control usage for a route, the current code shape cannot be statically analyzed to know which feature-control keys the route may use.

Open the dynamic usage report under tmp/feature-control-route-analyzer/<service>. Each item includes:

  • route
  • source file
  • line and column
  • expression
  • reason
  • any resolved static keys or regexes

Contact web-infra-eng to check whether this is a limitation of the analyzer. You can also inspect the code that introduces dynamic feature-control usage and see whether the possible feature-control key values can be constrained into a static list, object map, enum, or another pattern the analyzer can understand.

Client-side code warns that a feature key is missing

If you see [Feature_Key_Missing] in the browser console or in Datadog Logs, some feature-control keys were used at runtime but were trimmed from the route payload.

Contact web-infra-eng to adjust the analyzer so it can understand the missing usage pattern. If the issue is severe, temporarily limit trimming by removing the affected route or service from featureControlTrimScope.ts, or by removing the service from fc-trim.properties.service in feature-control configuration.