Device-local post-filter gates
Remote feature flags answer: “Should this user see this feature?” — based on rollout percentage, segments, and rules evaluated on the Toggly worker.
Some products also need a second question answered on the device:
“Has this user opted in on this phone?”
That second answer lives in iOS Settings, localStorage, a debug menu, or an in-app beta toggle. It is not in the Toggly dashboard, and it can change instantly while the user is in your app — without waiting for a network round-trip.
Post-filter gates let you combine both answers cleanly:
effective(key) = remote(key) AND localPrerequisite(key)
remote(key)— signed boolean fromevaluated-signed(rollouts, targeting, %)localPrerequisite(key)— your device-local switch, read synchronously when the flag is checked
The cached remote map is never mutated. Gates apply at read time, so a cached remote true cannot bypass a local switch that is OFF.
Think of a local gate as a master switch on the phone that controls a bundle of Toggly flags. Remote rollouts still decide eligibility; the local switch decides whether this device is allowed to show the feature right now.
Post-filter gates are for UX and product gating on the device. Always enforce security-sensitive behavior on your server as well.
The problem this solves
Without post-filter, teams usually pick one of these workarounds:
| Workaround | What goes wrong |
|---|---|
| Duplicate every flag in the dashboard | “Beta checkout” + “Checkout v2” for every gated feature; hard to keep in sync |
| Store opt-in only in app code | Remote flags and local state drift; UI can show features the user turned off |
| Overwrite the cached remote map when the switch flips | Stale remote true values can “stick” until the next fetch; turning OFF is not instant |
| Skip Toggly for gated features | Lose rollouts, targeting, and signed definitions for that whole bundle |
Post-filter keeps one remote flag per feature in Toggly and adds a local AND at read time.
A concrete example
You ship a redesigned checkout behind ApiV2Checkout and ApiV2Profile, with a 10% remote rollout. You also add Settings → Try the new checkout, stored in localStorage.
What you want:
| User state | Remote rollout | Local toggle | Checkout UI |
|---|---|---|---|
| Not in rollout | false | ON or OFF | Hidden |
| In rollout, toggle OFF | true | OFF | Hidden immediately |
| In rollout, toggle ON | true | ON | Shown |
| Turns toggle OFF while using app | true (cached) | OFF | Hidden immediately — no fetch |
Register one local gate that covers both flag keys:
localGates: [{
id: 'apiRedesign',
flagKeys: ['ApiV2Checkout', 'ApiV2Profile'],
isEnabled: () => readApiRedesignSetting(), // e.g. localStorage
}]
When the user flips the Settings switch OFF, call notifyLocalGatesChanged(). Every isFeatureOn, <Feature>, and $flag read recomputes remote AND local without a network call.
How it fits in the client SDK flow
flowchart LR
subgraph worker [Toggly worker]
R[evaluated-signed]
end
subgraph device [Your app]
C[Cached remote booleans]
L[Local gate isEnabled]
E["effective = remote AND local"]
UI[UI / components]
end
R -->|fetch / refresh| C
C --> E
L --> E
E --> UI
- Fetch —
refresh()loads remote booleans (signedevaluated-signedresponse). - Cache — SDK stores remote values; local gates do not change this cache.
- Read — Each flag check runs
applyLocalGate(remote, key, gates). - Local change — User toggles Settings → update local state →
notifyLocalGatesChanged()→ UI re-reads effective values.
For response format details, see Evaluated-signed.
Three kinds of flags in your app
| Kind | Where it lives | Example |
|---|---|---|
| Remote-only | Toggly worker only | Kill switch, % rollout |
| Remote + device-gated | Toggly + local gate | Beta UI behind Settings toggle |
| Pure device | App only, not in Toggly | Debug overlay, one-off dev tools |
Use post-filter for the middle row: remote rules stay in the dashboard; the device owns the opt-in switch.
Post-filter vs worker traits
Both can express “only show when context X”. Choose based on where the truth lives and how fast it must update.
| Post-filter (local AND) | Traits → worker | |
|---|---|---|
| Best for | Settings toggles, localStorage, OS permissions on this device | Dashboard rules using user/org attributes |
| Turn OFF latency | Instant (no network) | Next fetch (or WebSocket refresh) |
| Turn ON latency | refresh() then read | Next fetch |
| Who configures rules | App code registers gates | Toggly dashboard segments / % |
They can coexist: worker traits for “is this user in the beta cohort?” and a local gate for “has this device opted in?”
Toggle lifecycle
| Event | What to do |
|---|---|
| App launch | Register gates → refresh() → reads apply post-filter |
| User turns master switch OFF | Update local setting → notifyLocalGatesChanged() (no network) |
| User turns master switch ON | Update local setting → refresh() → notifyLocalGatesChanged() |
| Foreground / periodic refresh | Existing refresh; post-filter still applied on every read |
JavaScript example
import Toggly from '@ops-ai/feature-flags-toggly';
let apiRedesignEnabled = false;
await Toggly.init({
appKey: 'your-app-key',
environment: 'Production',
localGates: [{
id: 'apiRedesign',
flagKeys: ['ApiV2Checkout', 'ApiV2Profile'],
isEnabled: () => apiRedesignEnabled,
}],
});
// User turns OFF in Settings — instant, no fetch
apiRedesignEnabled = false;
Toggly.notifyLocalGatesChanged();
// User turns ON — refresh remote rollouts, then notify UI
apiRedesignEnabled = true;
await Toggly.refresh();
Toggly.notifyLocalGatesChanged();
if (Toggly.isFeatureOn('ApiV2Checkout')) {
// effective: remote AND apiRedesignEnabled
}
Subscribe UI to local gate changes:
const unsub = Toggly.subscribeLocalGatesChanged(() => {
renderShell();
});
Flutter example
var apiRedesignEnabled = false;
await Toggly.init(
appKey: 'your-app-key',
config: TogglyConfig(
localGates: [
LocalGate(
id: 'apiRedesign',
flagKeys: ['ApiV2Checkout', 'ApiV2Profile'],
isEnabled: () => apiRedesignEnabled,
),
],
),
);
apiRedesignEnabled = false;
Toggly.notifyLocalGatesChanged();
// Feature widget, FeatureGateBuilder, and evaluateFeatureGate re-read effective flags
API summary (cross-SDK)
| API | Purpose |
|---|---|
localGates / setLocalGates | Static registry: gate id → flag keys + isEnabled() |
notifyLocalGatesChanged | Recompute effective flags locally; notify UI (no network) |
subscribeLocalGatesChanged | Subscribe to local gate changes (JS-family SDKs) |
onLocalGatesChanged | Stream of local gate changes (Flutter); FeatureGateBuilder listens automatically |
Framework wrappers (Feature, FeatureGateBuilder, *featureGateBuilder, useFeatureGate, useFeatureFlag, $flag, $gate, etc.) subscribe to local gate notifications where applicable.
Per-SDK references
- JavaScript (vanilla)
- React Native
- Flutter
- React, Angular, Vue, Astro — same
localGates/notifyLocalGatesChangedcontract on the embedded client