Skip to Content
FeatureBuild-time planning

These pages document @evanion/feature 0.1.1, the version on npm.

Build-time planning

To optimize performance, you can determine which feature flags can be settled during your build process and which must wait for a user request. The plan() function performs this split, ensuring that features requiring a targetingKey remain deferred since a build has no specific customer.

Planning a Build

When using @evanion/feature, a static build can bake a flag that is “on for everyone” directly into the HTML. However, a flag that is “on for booksellers only” must wait for the request to identify the user; if written as a boolean during the build, it would incorrectly ship to everyone or nobody.

To handle this, plan() evaluates your feature store against the context available at build time—such as { game: 'urn:game:azul' } for a build rendering one page per game—and returns a PlanEntry for every feature.

import { } from '@evanion/feature'; const = ([ { : 'new-checkout', : true, : [{ : 'a-share', : { : 10, : 'targetingKey' } }], }, ]); .({ : 'urn:game:azul' }); // -> { 'new-checkout': { key: 'new-checkout', resolved: 'deferred', needs: ['targetingKey'] } }

In this example, the new checkout is deferred because its rollout requires a targetingKey, which is unavailable during the build. Settled entries follow the same decision logic as resolve(). Consequently, for any feature where freezeTimeAtBuild is not set, a request providing the same context as the build will receive the same enabled result.

Understanding the Plan Entry

Depending on whether the build context can settle the outcome, plan() returns one of three types of entries for each feature:

  1. A Fully Resolved Entry: The build has sufficient information to make a final call (e.g., the feature is off, a parent is settled off, or the build refutes every rule). It returns resolved: true | false along with a decision.
  2. A Deferred Entry without a Decision: Nothing about the feature is settled. The entry is marked resolved: 'deferred' with no decision. The needs array lists exactly which fields the request must provide to resolve it, sorted.
  3. A Deferred Entry with a Decision: The build settles enablement but cannot determine the variant. It returns resolved: 'deferred' with a decision containing enabled and reason, but no variant. In this case, needs specifies targetingKey.

The resolvePlan function processes these states uniquely. It returns a settled entry’s decision as-is without re-running logic. For deferred entries without a decision, it calculates the outcome against the client’s context. For deferred entries that already have a decision, it preserves the build’s enabled and reason while computing only the variant assignment; this prevents a richer client context from flipping an enablement the build already settled.

How Rules are Settled

A rule is settled at build time if it can be decided without the missing context:

  • Refuted Rules: If a rule’s condition is false regardless of the missing field, the rule is settled off. For example, if the build is for Azul’s page and a rule requires Spirit Island, that rule is refuted regardless of the role provided by the request.
  • Global Defaults: A feature with enabled: false is settled off regardless of its rules. A feature with no rules and enabled: true is settled on, provided no parent is deferred and no variant split requires a targetingKey.
import { } from '@evanion/feature'; const = ([ { : 'demo-night-booking', : true, : [ { : 'spirit-island-preview', : [ { : 'game', : 'eq', : 'urn:game:spirit-island' }, { : 'role', : 'eq', : 'bookseller' }, ], }, { : 'everyone', : [] }, ], }, ]); .({ : 'urn:game:azul' })['demo-night-booking']; // -> { key: 'demo-night-booking', resolved: true, needs: [], decision: { key: 'demo-night-booking', enabled: true, reason: 'rule-match', rule: 'everyone' } }

A planned breakdown names a failed condition only when every request would encounter that same failure. When plan() encounters a condition it cannot read, the outcome names the rule and omits failed. Therefore, a UI reading a planned decision should check for failed before naming a condition:

import { } from '@evanion/feature'; const = ([ { : 'demo-night-booking', : true, : [ { : 'spirit-island-preview', : [ { : 'role', : 'eq', : 'bookseller' }, { : 'game', : 'eq', : 'urn:game:spirit-island' }, ], }, ], }, ]); const = { : 'bookseller', : 'urn:game:azul' }; const = { : 'urn:game:azul' }; .()['demo-night-booking'].; // -> [{ rule: 'spirit-island-preview', matched: false, failed: { field: 'game', op: 'eq', value: 'urn:game:spirit-island' } }] .()['demo-night-booking'].?.; // -> [{ rule: 'spirit-island-preview', matched: false }]

Features with Variants

plan() treats enablement and variant splits as separate concerns. A feature with no rules is settled on; however, while its split still requires a targetingKey, the entry remains 'deferred' and carries a decision without a variant. Any filter that keeps only boolean entries will exclude such a feature from the settled set.

import { } from '@evanion/feature'; const = ([ { : 'checkout-cta', : true, : [ { : 'control', : 50 }, { : 'blue', : 50, : { : 'Get it' } }, ], }, ]); .({})['checkout-cta']; // -> { key: 'checkout-cta', resolved: 'deferred', needs: ['targetingKey'], decision: { key: 'checkout-cta', enabled: true, reason: 'default-on' } } .({ : 'user-1' })['checkout-cta']; // -> { key: 'checkout-cta', resolved: true, needs: [], decision: { key: 'checkout-cta', enabled: true, reason: 'default-on', variant: 'control', assignment: { source: 'weighted', by: 'targetingKey', bucket: 0.17582221026532352 } } }

What a Prerendered Page May Bake In

A decision settled by the build is one decided by the build’s own context; any decision requiring the subject must be deferred to the request.

Because assignVariant requires the subject, a build can bake in an enablement whose rules ignore context, but it cannot bake in a variant assignment. As seen in the variant example above, a feature can be enabled for everyone while the entry remains 'deferred' because the split needs a targetingKey. Once a targetingKey is provided, the call settles a variant. The following split example demonstrates what a prerendered page ships: settled enablements are included in the HTML, while deferred keys wait for the request.

Handling Time Windows

Time is handled as a special case in @evanion/feature. By default, plan() withholds now from feature rules because freezing a date window depends on your deployment frequency. A window remains deferred even if the build’s clock has already passed the window boundary.

To change this, you can use the freezeTimeAtBuild property in your configuration to opt into freezing time windows at build time. This is useful for sites that rebuild on a strict schedule (e.g., hourly) and can tolerate a small delay in activation. A frozen window will disagree with the request from the moment the boundary passes until the next build occurs.

The example below shows demo-night-booking with the flag set and gift-cards without it, even though they share the same rule.

import { } from '@evanion/feature'; const = ([ { : 'demo-night-booking', : true, : true, : [ { : 'from-december', : [{ : 'now', : 'after', : '2026-12-01T00:00:00Z' }], }, ], }, { : 'gift-cards', : true, : [ { : 'from-december', : [{ : 'now', : 'after', : '2026-12-01T00:00:00Z' }], }, ], }, ]); const = .({ : new ('2026-12-02T00:00:00Z') }); ['demo-night-booking']; // -> { key: 'demo-night-booking', resolved: true, needs: [], decision: { key: 'demo-night-booking', enabled: true, reason: 'rule-match', rule: 'from-december' } } ['gift-cards']; // -> { key: 'gift-cards', resolved: 'deferred', needs: ['now'] }

To manage features that resolve at different times, you choose between a plan and a snapshot.

To allow a feature like gift-cards to be resolved locally by the client, you write a plan into the bundle instead of a snapshot. The fence above demonstrates a plan() call. A plan contains no instant or origin and leaves gift-cards as a 'deferred' entry. The client then calls resolvePlan on that plan. Since resolvePlan uses the client’s own clock for the deferred remainder, gift-cards opens on the client’s December 1st, not the instant the bundle was built. Settled entries retain their answers. If the context provides an explicit now, it overrides both the now option and the client clock.

When you require a record of exactly when and where features were settled, @evanion/feature uses a DecisionSet. Features.snapshot returns a DecisionSet, recording the instant it settled in now and its source in origin. A pipeline writing a set into a bundle passes origin: 'build', as now may be days old and the consumer should use its own clock for the remainder. This option states provenance without moving resolution.

Unlike a plan, snapshot ignores freezeTimeAtBuild and settles gift-cards at the moment of the call; thus, a build-origin set does not leave it deferred. For details on how a consumer uses origin, see Hydration.

Implementing the Plan in your App

The @evanion/feature plan() function organizes your features into two distinct paths:

The plan() pipeline: settled decisions ship as static data; deferred rules are resolved at runtime.
import { } from '@evanion/feature'; const = ([ { : 'new-checkout', : true, : [{ : 'a-share', : { : 10 } }], }, { : 'gift-cards', : true }, { : 'demo-night-booking', : false }, ]); const = .({ : 'urn:game:azul' }); const = .( .() .(([, ]) => . !== 'deferred') .(([, ]) => [, .]), ); const = .() .(() => . === 'deferred') .(() => .); ; // -> { 'gift-cards': { key: 'gift-cards', enabled: true, reason: 'default-on' }, 'demo-night-booking': { key: 'demo-night-booking', enabled: false, reason: 'explicitly-off' } } ; // -> ['new-checkout']

The Workflow

  1. At Build Time: Execute plan(). Extract all resolved decisions into a settled record and ship this as the decisions prop to your <FeatureProvider />.
  2. At Runtime: Call resolve(context) using the data provided by the request; the context it must assemble is the union of the needs from the deferred entries. The provider uses the decisions record as-is and resolves nothing else. Components reading only settled keys render from the build’s record, while components reading a deferred key provide the record returned by resolve(context), which contains every key.

Your build can omit the branch behind a flag settled as false. A deferred flag must keep its branch because the request that enables it will need it. Settled flags are shipped in the HTML, meaning only deferred keys incur the cost of a request-time resolve.

Observing reports each plan, resolve, and toggle call as an event.

Last updated on