Flags
pb.flags gives your web app feature flags with a local cache, live polling and synchronous typed reads. In the browser the SDK keeps a snapshot of every flag warm in the background, so checking a flag is a plain in-memory lookup — no await, no loading state. Flag definitions live on the Environment, not in your repository: you write them with palbase flags and they take effect immediately, with no deploy. On the server the same object exists but stays completely inert until you ask it for something.
Quick example
import { pb } from '@palbase/web';
// Synchronous reads from the local cache:
if (pb.flags.isEnabled('new_todo_editor')) {
renderNewEditor();
}
const maxPinned = pb.flags.getInt('max_pinned_todos', 3);
const theme = pb.flags.getString('default_theme', 'light');
There is nothing to start or configure: importing palbase/client (see Codegen) configures pb, and the first time you touch pb.flags in a browser the cache machinery starts on its own.
If you read flags immediately on page load, wait for the first snapshot once:
await pb.flags.ready();
const enabled = pb.flags.isEnabled('new_todo_editor');
How the cache stays fresh
| Runtime | Behavior |
|---|---|
| Browser | Auto-starts on first pb.flags access: hydrates the persisted snapshot from localStorage, fetches a cold snapshot, then delta-polls every 30 seconds. Polling pauses while the tab is hidden and resumes when it becomes visible. |
| Server (SSR, Route Handlers, Node) | Never auto-starts, never creates timers, never touches storage. ready() and refresh() are one-shot fetches — a server request that reads flags pays exactly one HTTP call. |
The server pool is not merely idle: its timer seam throws (palbe flags: polling is disabled server-side) and its storage and visibility hooks are null, so a stray timer is a loud error rather than a silent leak in a long-lived process.
Three routes on your Environment carry all of it:
| Read | Route |
|---|---|
| Cold snapshot | GET /v1/user-flags/snapshot |
| Delta poll | GET /v1/user-flags/delta?since=<version> |
| Variant | GET /v1/user-flags |
Note: Flags are resolved per user on the backend — the Environment's definition, plus any override written for the signed-in user. When the identity changes (sign-in, sign-out, a different user), the SDK re-snapshots automatically, so you never call
refresh()after an auth change. The pool watches the token, not the cached profile, so a session restored on reload gets the right baseline before the profile arrives.
Where the persisted snapshot lives
The browser snapshot is written to localStorage under a key suffixed with the identity the SDK parses out of your publishable key. On the Palbase cloud that identity is the compile-time constant project for every Environment, so the key is literally:
palbe.flags.project
Warning: Two Palbase projects served from the same browser origin share that snapshot, and whichever loaded last wins. This page used to promise per-Environment isolation; the key cannot provide it, because the ref is not in the key. Put two Environments on different origins. See the two API keys.
Lifecycle methods
| Method | Returns | Description |
|---|---|---|
pb.flags.ready() | Promise<void> | Resolves once the first snapshot (or the persisted hydrate) is available. Safe to call repeatedly. |
pb.flags.refresh() | Promise<void> | Forces an immediate re-snapshot without waiting for the next poll. |
pb.flags.destroy() | void | Stops polling and detaches every listener (auth, visibility, subscribers). Rarely needed in an app. |
Reading flags (synchronous)
All value reads are synchronous lookups against the cached snapshot. A flag value can be any JSON value:
type FlagValue = boolean | number | string | null | FlagValue[] | { [key: string]: FlagValue };
| Method | Signature | Behavior |
|---|---|---|
all() | (): Readonly<Record<string, FlagValue>> | Frozen snapshot of every cached flag. Object identity is stable until a value actually changes — safe in React dependency arrays. |
get(key) | (key: string): FlagValue | undefined | Raw cached value, or undefined when the key is not in the cache. |
isEnabled(key, fallback?) | (key: string, fallback = false): boolean | true only when the cached value is strictly true; fallback when the key is absent. |
bool(key, fallback?) | (key: string, fallback = false): boolean | Alias of isEnabled (iOS parity). |
getString(key, fallback) | (key: string, fallback: string): string | The cached value when it is a string, else fallback. |
getInt(key, fallback) | (key: string, fallback: number): number | The cached value when it is an integer, else fallback. |
getDouble(key, fallback) | (key: string, fallback: number): number | The cached value when it is any number, integers included, else fallback. |
pb.flags.all(); // { new_todo_editor: true, max_pinned_todos: 5, … }
pb.flags.get('max_pinned_todos'); // 5
pb.flags.isEnabled('new_todo_editor'); // true
pb.flags.getString('default_theme', 'light'); // 'dark'
pb.flags.getInt('max_pinned_todos', 3); // 5
pb.flags.getDouble('reminder_delay_hours', 1.5);
Note: The typed getters never throw on a type mismatch — if a flag holds a string,
getIntreturns thefallback, not an error. Useget()when you need to inspect the raw value yourself.
Variants (async)
getVariant resolves the multivariate variant assigned to the current user — which copy variant of an onboarding flow they landed in, for example:
const variant = await pb.flags.getVariant('onboarding_flow');
// 'short' | 'detailed' | null
| Outcome | Result |
|---|---|
| The flag has an assigned variant | The variant name (string) |
| The flag has no variant, or the server answers with an error envelope | null |
| Invalid flag name | Throws BackendError of kind validation, code invalid_flag_name |
| The read fails on the wire — offline, DNS failure, retries exhausted | Also throws invalid_flag_name, on a perfectly valid name |
Warning: Unlike every other flag read,
getVariantis async — it is a network read (GET /v1/user-flags), not a cache lookup, because variant metadata is not part of the polled snapshot. This is a deliberate deviation from the iOS SDK, where the same call reads a synchronous cache. Resolve the variant once, when a screen mounts, and store the result; do not call it in a render path.
Warning:
invalid_flag_namedoes not mean the name was invalid.getVariantwraps its transport call in a blanketcatchthat converts every thrown error into that one code, and a genuine network failure throws rather than returning an envelope. Only an HTTP error response resolves tonull. Treatinvalid_flag_nameas "the variant could not be read", check the name yourself if you need to distinguish the two, and fall back to your default variant either way.
Subscribing to changes
All subscription methods return an Unsubscribe function — call it to detach.
onChange — any change
const unsubscribe = pb.flags.onChange(() => {
console.log('flags updated', pb.flags.all());
});
// later
unsubscribe();
subscribeKey — one key
Fires only when that key's value actually changes, with the new value. undefined means the flag was deleted.
const unsubscribe = pb.flags.subscribeKey('max_pinned_todos', (value) => {
console.log('pin limit is now', value);
});
Note: Primitives are compared by identity, objects and arrays by their JSON text — which is key-order-sensitive. A server that reorders keys without changing values can fire a spurious callback. Treat a callback as probably-changed, not as a guaranteed diff.
changes() — async iterator
changes() yields the full flags view on every change. Breaking out of the loop (or return, or throw) detaches the listener cleanly, even while a next() is still pending — that resolves { done: true } instead of hanging.
for await (const flags of pb.flags.changes()) {
if (flags.maintenance_mode === true) {
showMaintenanceBanner();
break; // detaches the listener
}
}
Using flags in React
In components, prefer the hooks from @palbase/web/react — they subscribe per key and re-render only when that value changes:
import { useFlag } from '@palbase/web/react';
function TodoList() {
const newEditor = useFlag('new_todo_editor', false);
return newEditor ? <NewEditor /> : <LegacyEditor />;
}
useFlag and useFlags are safe to mount before configuration has run — they fall back to the value you pass and re-subscribe when the palbase/client barrel configures pb. Memoize an object fallback; passing a fresh object literal each render defeats the snapshot cache. See React Hooks.
Server-side reads
Flags are resolved for the signed-in user of the runtime that owns the pool. The process-global pb on a server has no user, so it resolves anonymous values for everybody. In Next.js, build a request-scoped client first:
import { pbServer } from '@palbase/web/next';
export default async function Page() {
const pb = await pbServer(); // reads this request's session cookies
await pb.flags.ready(); // exactly one HTTP call
const enabled = pb.flags.isEnabled('new_todo_editor');
return enabled ? <NewEditor /> : <LegacyEditor />;
}
Nothing polls, nothing is stored and no timer is created on this path. See Next.js.
Where flags come from
A flag definition is a typed, project-wide default that lives on the Environment. It is written directly to the stack and is live the moment the command returns — there is no file in your repository, nothing to commit and nothing to deploy:
palbase flags add new_todo_editor --type boolean --default false
palbase flags add default_theme --type string --default '"light"' --variants light,dark
palbase flags list
palbase flags remove default_theme
Per-user overrides are separate runtime state on the same Environment (palbase flags user set <user-id> <key> --type boolean --value true), and your backend can read and write them from an endpoint.
Note: Older documentation described declaring flags in
config/flags.tsand applying them on deploy. No bundler readsconfig/any more — it is gone — andpalbase pushapplies nothing from it. A leftoverconfig/flags.tsis inert — delete it.
Related
- Stack Settings —
palbase flags add,remove,listand per-user overrides - Flags — reading flags and writing overrides from your backend
- React Hooks —
useFlaganduseFlags - Next.js —
pbServer()and request-scoped reads - Error Handling — the
BackendErrorshapegetVariantcan throw - the two API keys — why the storage key is not per Environment
- Flags — the iOS counterpart of this page