Palbase
Sign inGet started

Web SDK

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

RuntimeBehavior
BrowserAuto-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:

ReadRoute
Cold snapshotGET /v1/user-flags/snapshot
Delta pollGET /v1/user-flags/delta?since=<version>
VariantGET /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

MethodReturnsDescription
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()voidStops 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 };
MethodSignatureBehavior
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 | undefinedRaw cached value, or undefined when the key is not in the cache.
isEnabled(key, fallback?)(key: string, fallback = false): booleantrue only when the cached value is strictly true; fallback when the key is absent.
bool(key, fallback?)(key: string, fallback = false): booleanAlias of isEnabled (iOS parity).
getString(key, fallback)(key: string, fallback: string): stringThe cached value when it is a string, else fallback.
getInt(key, fallback)(key: string, fallback: number): numberThe cached value when it is an integer, else fallback.
getDouble(key, fallback)(key: string, fallback: number): numberThe 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, getInt returns the fallback, not an error. Use get() 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
OutcomeResult
The flag has an assigned variantThe variant name (string)
The flag has no variant, or the server answers with an error envelopenull
Invalid flag nameThrows BackendError of kind validation, code invalid_flag_name
The read fails on the wire — offline, DNS failure, retries exhaustedAlso throws invalid_flag_name, on a perfectly valid name

Warning: Unlike every other flag read, getVariant is 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_name does not mean the name was invalid. getVariant wraps its transport call in a blanket catch that 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 to null. Treat invalid_flag_name as "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.ts and applying them on deploy. No bundler reads config/ any more — it is gone — and palbase push applies nothing from it. A leftover config/flags.ts is inert — delete it.

  • Stack Settingspalbase flags add, remove, list and per-user overrides
  • Flags — reading flags and writing overrides from your backend
  • React HooksuseFlag and useFlags
  • Next.jspbServer() and request-scoped reads
  • Error Handling — the BackendError shape getVariant can throw
  • the two API keys — why the storage key is not per Environment
  • Flags — the iOS counterpart of this page