nimbus-prefs

SkillDev tools

Use this skill when a Firefox preference has to be remotely controllable: putting a new feature behind a pref so it can be experimented on, rolled out gradually, or disabled remotely via Nimbus. Covers declaring prefs (StaticPrefList.yaml, firefox.js), adding a feature to toolkit/components/nimbus/FeatureManifest.yaml, choosing between `fallbackPref` and `setPref`, reading values from JS and C++, recording exposure events, and pairing the pref with Glean telemetry. Trigger on "put this behind a pref", "make this Nimbus-controllable", "add a feature gate", "run an experiment on this", "setPref vs fallbackPref".

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the nimbus-prefs skill

What this skill tells your AI

The instructions your AI receives, as published by mozilla/enterprise-firefox in .agents/skills/nimbus-prefs/SKILL.md and read by ahel’s review.

For background on Nimbus itself — experiment design, the Experimenter console, analysis — see experimenter.info. This skill covers the in-tree side.

Goal

New user-facing features should ship behind a Nimbus feature, so they can be experimented on, rolled out gradually, and turned off remotely without a ride-along release.

A Nimbus feature does not require a pref, and the platform team's recommended default is not to add one. If only your own JS reads the value, read it straight from the Nimbus API and skip the pref entirely. Add a pref when code you do not control has to read the value, notably C++ and Rust. See Pref Experiments.

Up to three names are involved, and the build does not check that they refer to anything: the pref name, the Nimbus variable name, and the Glean metric name are independent hand-written strings. The manifest is checked for internal consistency at build time, but nothing verifies that a pref you name actually exists. Keeping the three aligned is on you and the reviewer.

Pick a mechanism

You wantUseDoes Nimbus write the pref?What an enrollment changesCode reads
A default that experiments may overridefallbackPrefnothe value getVariable() returns, in memory onlyNimbusFeatures.<id>.getVariable()
An experiment to change a pref other code already reads (incl. C++ StaticPrefs)setPrefyesthe pref itself, restored on unenrollmentServices.prefs / StaticPrefs:: as usual
A value with no pref at allneithern/athe value getVariable() returnsgetVariable() + ?? DEFAULT at every call site
A pref Nimbus must never touchplain prefnonothingServices.prefs

setPref and fallbackPref are mutually exclusive on one variable (enforced by the schema).

Prefer no pref at all for new JS-only feature gates. Use fallbackPref when the value also has to be settable from about:config: one declaration, and onUpdate fires for both Nimbus changes and pref changes. Use setPref when the value must reach code you don't control — notably anything reading a StaticPrefList.yaml pref from C++ or Rust.

Step 1 — declare the pref

  • C++ or Rust needs to read itmodules/libpref/init/StaticPrefList.yaml. Requires name, type, value, mirror; use mirror: always if Nimbus is going to change it (see the C++ section below). Keep the alphabetical section order.
  • JS/front-end onlybrowser/app/profile/firefox.js (Firefox-only) or modules/libpref/init/all.js (all apps). Android-only: mobile/android/app/geckoview-prefs.js.

Default the feature off.

Do not declare the same pref in both StaticPrefList.yaml and a .js pref file with the same value — ./mach lint -l lintpref rejects that.

Make sure a fallbackPref names a pref that really exists. Nimbus installs defineLazyPreferenceGetter(..., fallbackPref, null, ...), so if the pref is not declared anywhere getVariable() returns null. A caller writing ?? DEFAULT still gets its default (?? catches null), so this is usually not a wrong-value bug — but:

  • a typo in the pref name means the override silently never works, and nothing tells you;
  • callers that use the value without a nullish guard get null, which coerces to 0 in arithmetic — an int variable silently becomes a zero interval or a zero limit instead of failing visibly (undefined, and therefore NaN, is the no fallbackPref case);
  • the manifest claims a pref supplies the default when it does not, which misleads the next reader and hides the knob from about:config.

A deliberately undeclared pref is a legitimate pattern when the variable is a pure override and the real default is a JS constant — aboutwelcome.backdrop works this way, defaulting via featureConfig.backdrop ?? defaults.backdrop in AboutWelcomeChild.sys.mjs. If that is what you are doing, say so in the variable's description so it does not read as a mistake.

Step 2 — add the Nimbus feature

Features are top-level keys in toolkit/components/nimbus/FeatureManifest.yaml. description, owner, hasExposure and variables are required; exposureDescription is required when hasExposure: true.

contentRelevancy:
  description: >-
    A feature for interest-based content relevance ranking and personalization
    for Firefox.
  owner: disco-team@mozilla.com
  hasExposure: false
  variables:
    enabled:
      description: Enable this feature
      type: boolean
      fallbackPref: toolkit.contentRelevancy.enabled
    timerInterval:
      description: >-
        The interval (in seconds) of the background update timer for the content
        relevancy manager
      type: int
      setPref:
        branch: user
        pref: toolkit.contentRelevancy.timerInterval
  • type is one of int, string, boolean, json. It must match the pref's type. PrefUtils.setPref dispatches on the value's JS type, so a boolean value aimed at a pref already registered as int calls setBoolPref and throws NS_ERROR_UNEXPECTED mid-enrollment. Nothing catches it, and because addEnrollment() runs before the prefs are applied, the enrollment is stored while its prefs, its pref observers and its enrollment telemetry are all skipped. It does not look wrong in telemetry, it looks absent.
    • A json variable writes JSON.stringify(value), so its setPref target must be a string pref (and a json fallbackPref reads a string pref and parses it). json setPref requires Firefox 126+.
    • There is no float type. libpref stores float prefs as strings, so drive one from a string variable and parse at the call site. An int variable pointed at a float pref is the NS_ERROR_UNEXPECTED case above.
  • setPref needs branch: user or branch: default. Default-branch values are re-applied every startup; user-branch values persist to prefs.js and show as modified in about:config.
  • Add enum: for string/int variables with a closed set of legal values. Experimenter validates recipe feature values against the published manifest, enums included, when a recipe is authored, and tests that build enrollments through NimbusTestUtils check the same thing locally (validateFeatureValueEnum). The client re-checks as well: RemoteSettingsExperimentLoader validates each branch against a schema generated from the manifest and refuses to enroll in an invalid recipe, so an out-of-enum value surfaces as a client-side enrollment failure in the logs (governed by nimbus.validation.enabled, true on all channels; opted out per recipe with featureValidationOptOut). That generated schema only carries enum for string variables, so int enums are not enforced on the client.
  • Do not use isEarlyStartup. It is deprecated behind a frozen allowlist (bug 1875331); the build fails if you add a new one.
  • A pref cannot be the setPref target of two variables, and a pref used as a fallbackPref anywhere in the manifest is not supposed to be a setPref target anywhere else (documented as build-time enforced, though the check in generate_feature_manifest.py looks unreachable in practice, so do not rely on it catching you). A feature that sets allowCoenrollment: true cannot use setPref at all. Some prefs are permanently off-limits (DISALLOWED_PREFS in toolkit/components/nimbus/generate/generate_feature_manifest.py: disabling telemetry or Nimbus, repointing Remote Settings, nimbus.debug, and the sandbox and automation prefs).

The manifest is validated at build time by generate_feature_manifest.py, so a malformed entry breaks ./mach build, not a lint.

Step 3 — read the value

JS

ChromeUtils.defineESModuleGetters(lazy, {
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
});

const NIMBUS_VARIABLE_ENABLED = "enabled";

get shouldEnable() {
  return (
    lazy.NimbusFeatures.contentRelevancy.getVariable(
      NIMBUS_VARIABLE_ENABLED
    ) ?? false
  );
}

Hoist variable names into constants rather than inlining string literals. Always write ?? DEFAULT: getVariable() returns undefined when there is no enrollment and no fallbackPref.

Wait for the store before the first read. Until Nimbus has loaded its enrollments, getVariable() silently returns the fallbackPref value (or undefined) even for a client that is enrolled. There is no throw and no warning, and the caller cannot tell "not enrolled" from "not loaded yet".

Where that sits in startup: Nimbus initialises from the browser-before-ui-startup category, but it waits for sessionstore-windows-restored before loading enrollments. Anything that runs before the first window has been restored, which is most component initialisation, sees the unenrolled state. Only isEarlyStartup features are exempt, and that flag is closed to new features.

Either await readiness before reading:

await lazy.NimbusFeatures.contentRelevancy.ready();
const enabled = lazy.NimbusFeatures.contentRelevancy.getVariable("enabled") ?? false;

or read eagerly and re-read from onUpdate, which fires with reason "feature-enrollments-loaded" once the store is up. Do not read once in init() and cache the result: that is the most common way to pin a feature to its unenrolled value for an entire session.

Register and unregister listeners symmetrically:

// This will handle both Nimbus updates and pref changes.
lazy.NimbusFeatures.contentRelevancy.onUpdate(this._nimbusUpdateCallback);
// ... and in uninit():
lazy.NimbusFeatures.contentRelevancy.offUpdate(this._nimbusUpdateCallback);

A fallbackPref change emits an update with reason "pref-updated", so one onUpdate listener covers both sources — you do not need a separate pref observer.

One catch: onUpdate() only registers a listener on the experiment store. The pref observer lives in the XPCOMUtils.defineLazyPreferenceGetter that fallbackPref installs, and that observer is only added when the lazy getter is first read. If you register onUpdate in init() and never touch the variable, an about:config flip fires nothing.

Reading once during initialisation is the fix, but read with getAllVariables(), not getVariable(). getVariable() returns early on an experiment or rollout value and never reaches the pref getter, so while a client is enrolled it does not install the observer at all. getAllVariables() spreads every fallbackPref getter and therefore always does.

Reacting to change on the setPref path

All of the above is for variables you read through the Nimbus API. If your value arrives as a pref via setPref, onUpdate is not the mechanism: watch the pref the normal way, with Services.prefs.addObserver or a mirror: always static pref that keeps itself current.

You do have to watch it. setPref values are applied once Nimbus has started, which is after early startup, so a feature that reads the pref at init and caches the result is wrong for the whole session in which the client enrolls, and wrong again in the session where it unenrolls.

The one rule that differs from a normal pref observer: never write the pref from the handler. Any write unenrolls the client.

C++

mozilla::NimbusFeatures::GetBool/GetInt (toolkit/components/nimbus/lib/NimbusFeatures.h) take an explicit default and resolve experiment → rollout → fallbackPref → default. But they only see values for isEarlyStartup features, and that flag is closed to new features.

So for C++ and Rust: declare the pref in StaticPrefList.yaml with mirror: always, give the Nimbus variable a setPref pointing at it, and read it normally via StaticPrefs::. Nimbus writes the pref; your code never knows Nimbus exists.

For Rust the pref additionally needs rust: true in its StaticPrefList.yaml entry before static_prefs::pref!("your.pref") will resolve, and Rust reads the mirror variable directly rather than through the getter function.

Two constraints come with that, and both fail silently:

  • mirror: always. A mirror: once pref is snapshotted into its mirror variable and never refreshed, so an enrollment that writes it mid-session has no effect for the rest of the session and the feature simply is not remotely controllable. Delaying your own first read does not help: every once mirror in the process is filled in together, the first time any once pref's getter runs, which some unrelated caller will do early. The tell at a call site is the _AtStartup suffix libpref adds to a once getter.
  • Startup timing. setPref values are applied when Nimbus starts up (ExperimentManager._restoreEnrollmentPrefs), which is after early startup pref reads. With branch: default anything reading the pref before that point sees the in-tree default and the experiment looks like it did not apply. Use branch: user for prefs that may be read early: user-branch values are persisted in prefs.js, so _restoreEnrollmentPrefs can skip them and they are already set before early startup reads. Note this only holds from the second session after enrollment — on the session where the client first enrols, the pref is written mid-session, after early reads, whatever the branch. Do not expect a first-session effect. This is what isEarlyStartup used to paper over.

Step 4 — record exposure

If the feature branches user-visible behaviour, set hasExposure: true, write an exposureDescription saying exactly when exposure fires, and record it at the point where behaviour actually diverges — not at startup:

if (isExternal) {
  lazy.NimbusFeatures.externalLinkHandling.recordExposureEvent({ once: true });
}
const behavior =
  lazy.NimbusFeatures.externalLinkHandling.getVariable("openBehavior");

{ once: true } de-duplicates per process: the flag lives on the per-process feature object, so an exposure point that runs in content processes records once per content process.

hasExposure is declarative only. Nothing checks it, so hasExposure: true with no recordExposureEvent() call records nothing, and a call on a hasExposure: false feature still fires. Keep the two in step by hand.

Without exposure telemetry an experiment is still analysed, on the enrollment basis, which is Jetstream's default. What you lose is the exposure basis: the effect is then measured diluted across every enrolled client, including those who never encountered the feature, which on a feature only some enrolled clients reach can shrink a real effect below detectability. See Enrollment vs Exposure.

If your feature sets allowCoenrollment: true, the whole single-enrollment API is unavailable: getVariable(), getAllVariables() and getEnrollmentMetadata() all throw, you read values through getAllEnrollments(), and recordExposureEvent() must be passed the slug of the enrollment you are recording for. A co-enrolling feature also cannot use setPref or isEarlyStartup.

Step 5 — pair it with Glean

Add metrics to the component's own metrics.yaml (registered in toolkit/components/glean/metrics_index.py). Every metric needs expires.

Two rules that save real debugging:

  • Have telemetry read the same variable the code applied, not the raw pref. Then the reported value cannot diverge from the effective one.
  • Use static metric identifiers. In JS, Glean.myfeature.sawThing is a runtime lookup that yields undefined for a name not in metrics.yaml, so both a typo and a computed Glean.myfeature["saw" + suffix] throw at the .record() call. Nothing catches either at build time (C++ is different: the generated headers make it a compile error). A static identifier is still the right choice because it is greppable and can be checked by eye against metrics.yaml, which a concatenated one cannot.

Do not add prefs to Glean.preferences.userPrefs. It is explicitly frozen (modules/libpref/metrics.yaml), and the list it fossilises is DEFAULT_ENVIRONMENT_PREFS in toolkit/components/telemetry/app/Environment.sys.mjs. Instrument your pref with its own metric instead.

Resolution order and its traps

getVariable() resolves: experiment → rollout → fallbackPrefundefined.

  • The test is typeof !== "undefined", so an enrollment setting a variable to null, false or 0 wins over the pref. That is intended, and it surprises people.
  • getAllVariables({ defaultValues }) spreads { ...fallbackPrefValues, ...defaultValues, ...experimentValue } — so your defaultValues override the fallbackPref values, the opposite of what "default" suggests. Don't mix defaultValues with fallbackPref on the same feature.
  • getVariable() never reads a setPref pref, but the variable itself is still part of the enrollment, so while enrolled getVariable() does return its value — and returns undefined once unenrolled, while the pref is restored. Read setPref variables through Services.prefs / StaticPrefs::, not getVariable().
  • Renaming or deleting a setPref variable unenrolls live clients (PREF_VARIABLE_MISSING / PREF_VARIABLE_NO_LONGER / PREF_VARIABLE_CHANGED), and deleting the feature itself does too (INVALID_FEATURE). Check Experimenter for live recipes before touching an existing variable. Note this only bites enrollments that actually set a pref: a manifest change is harmless to an active experiment whose branch sets no pref-setting variable.
  • An unknown variable name throws only on Nightly and in automation — on Release it silently returns undefined. Test on Nightly.

Follow the good examples

  • fallbackPref done right — the enabled and ingestEnabled variables of contentRelevancy in the manifest + toolkit/components/contentrelevancy/ContentRelevancyManager.sys.mjs: named constants, onUpdate/offUpdate symmetry, explicit ?? false. The timerInterval variable in the same file is a setPref variable read as getVariable(...) ?? Services.prefs.getIntPref(...); the pref fallback is load-bearing because getVariable() returns undefined once the client unenrolls, and it is the restored pref that then carries the value. Reading the pref directly is the simpler rule for a setPref variable, per the resolution-order section below.
  • Exposure done rightexternalLinkHandling + browser/modules/BrowserDOMWindow.sys.mjs: typed enum, exposure at the branch point, and BrowserUsageTelemetry reading the same variable.
  • Isolation done rightipProtection + toolkit/components/ipprotection/IPPNimbusHelper.sys.mjs: a ~56-line helper holding all the Nimbus glue, with control-branch handling. Copy the shape, not the manifest entry: the feature is declared hasExposure: false while the helper does record exposure, which is the mismatch Step 4 warns about.

Anti-patterns

  • Declaring a Nimbus variable, then reading a different, hand-rolled pref with a raw string literal that is declared in no pref file. The names look related; nothing checks them.
  • hasExposure: false on a feature that does branch behaviour, which forfeits exposure-basis analysis and leaves the effect measured diluted across clients who never saw the feature.
  • No fallbackPref and no pref, so the default is an object literal duplicated at each call site and the copies drift.
  • Building Glean metric names by string concatenation.
  • isEarlyStartup on a variable instead of the feature — the schemas lack additionalProperties: false, so misplaced keys are silently ignored.

When NOT to add a Nimbus entry

Not every pref should be Nimbus-controllable. Leave these alone:

  • Temporary state written by the app — hasSeen*, *Dismissed, *Completed, counters, timestamps. modules/libpref/docs/index.md calls these "application data prefs".
  • User settings already exposed in about:preferences.
  • Debug and logging flags (*.loglevel).
  • Test-only prefs.
  • Web-exposed API and CSS gates (layout.css.*, dom.*.enabled interface flags) — those ship by release channel and WPT coverage, not by experiments. This is about exposing new syntax or interfaces to content; behaviour changes under a dom.* or network.* pref are routinely rolled out through Nimbus (see dom.security.https_first, network.cookie.CHIPS.enabled).

Verify

./mach lint -l lintpref .                  # no duplicate pref declarations
./mach build toolkit/components/nimbus     # validates FeatureManifest.yaml
./mach test toolkit/components/nimbus/test/unit
./mach test toolkit/components/nimbus/test/browser
./mach test toolkit/components/nimbus/test/python   # the manifest generator's own tests
./mach lint -W .                           # -W is required to see warnings

Check the behaviour with a real enrollment rather than by guessing.

The quickest path is Nimbus Developer Tools, which enrolls from a raw feature config with no recipe at all. Otherwise set nimbus.debug to true and open the opt-in URL:

about:studies?optin_slug=<slug>&optin_branch=<branch>

Append &optin_collection=nimbus-preview only for a recipe in Preview. On a live recipe that argument points the lookup at the wrong collection and the opt-in silently does nothing.

For automated coverage, write two kinds of test, because they fail for different reasons. SpecialPowers.pushPrefEnv proves your code does the right thing when the pref changes, and exercises none of the Nimbus wiring. One enrollment test built with NimbusTestUtils.enrollWithFeatureConfig (toolkit/components/nimbus/test/NimbusTestUtils.sys.mjs, in an xpcshell or browser test) proves the wiring: that the manifest variable really maps to the pref you think it does, and that the cleanup handler puts the pref back on unenrollment. Each is a few lines.

After it lands

Landing the manifest change is not the last step. Experimenter fetches toolkit/components/nimbus/FeatureManifest.yaml from mozilla-firefox/firefox on a bot schedule, as an unversioned copy plus one per release version, so a new feature does not appear the moment your patch lands. If it is not in the branches dropdown yet, the fetch has not run.

Two consequences when the experiment owner goes to build the recipe:

  • A feature is only selectable once it appears in the unversioned manifest. Features seen only in a versioned manifest are created disabled.
  • Branch feature values are validated against the schemas for the recipe's Firefox version range, so a recipe whose minimum version predates the release carrying your variable fails validation. Set the experiment's minimum Firefox version to the release your change ships in.

Signals

GitHub stars
22
Forks
44
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
nimbus-prefs-mozilla
Source
github.com/mozilla/enterprise-firefox