Flags
pb.flags is the feature-flag surface of the iOS SDK. Resolution — the Environment-wide default, then any override that applies to this user — happens server-side, so the SDK only ever holds already-resolved values. It keeps them in an observable, always-fresh cache and exposes them through synchronous, non-throwing reads. The cache is observable per key, so reading a flag inside a SwiftUI View body is all it takes to get live UI: when that flag changes, that view re-renders and nothing else does.
Flag definitions live on the Environment, not in your repository. You create them with palbase flags add or from the panel and they take effect immediately — there is no file to commit and no deploy to wait for. See Stack Settings for the writing side and Flags for reading and overriding them from backend code.
Quick example
import SwiftUI
import Palbe
struct TodoListView: View {
var body: some View {
List {
// Reading a flag in `body` registers a per-key observation:
// this view re-renders when (and only when) THIS flag changes.
if pb.flags.isEnabled("new_composer") {
NewTodoComposer()
} else {
LegacyTodoField()
}
}
}
}
No setup, no fetching, no loading state in the common case: the SDK hydrates from a disk cache instantly at launch, then syncs in the background — see How sync works.
Reading flags
All reads are synchronous, served from the in-memory cache, and never block or throw. The typed reads take a default that is returned when the flag is missing or cannot be coerced to the requested type.
| Method | Returns | Notes |
|---|---|---|
isEnabled(_ key: String, default: Bool = false) | Bool | Boolean flag |
bool(_ key: String, default: Bool = false) | Bool | Alias of isEnabled |
getString(_ key: String, default: String) | String | String flag |
getInt(_ key: String, default: Int) | Int | Integer flag |
getDouble(_ key: String, default: Double) | Double | Double flag |
getVariant(_ key: String) | String? | Variant name for a string flag with variants; nil when unset |
get(_ key: String) | FlagValue? | The raw resolved value box, if present |
all() | [String: FlagValue] | Every resolved flag in the current snapshot |
let pageSize = pb.flags.getInt("todo_page_size", default: 25)
let banner = pb.flags.getString("home_banner_text", default: "")
let listStyle = pb.flags.getVariant("list_style") ?? "compact"
Note:
pb.flagsis@MainActor— reads come from the main-actor-isolated observable store. SwiftUI bodies and most UI code are already on the main actor, so this is invisible in practice. From a background context, hop first:await MainActor.run { pb.flags.isEnabled("new_composer") }.
Reading pb.flags before anything else has touched pb triggers the SDK's lazy configuration from Palbase-Info.plist. If a store cannot be produced, a fresh empty one is handed back rather than trapping — every read then returns its default, and a view shows the fallback UI instead of crashing.
FlagValue
get(_:) and all() return values as FlagValue, an enum over the types a flag can hold on this platform:
public enum FlagValue: Sendable, Equatable, Codable {
case bool(Bool)
case string(String)
case int(Int)
case double(Double)
case null
}
There is no object or array case, and no JSON accessor on FlagsNamespace or FlagsStore. FlagValue carries lenient typed accessors that coerce across compatible representations:
| Accessor | Returns | Coercion rules |
|---|---|---|
boolValue | Bool? | .bool as-is; .int → != 0; .string → "true", "1", "yes", "on" (case-insensitive); otherwise nil |
stringValue | String? | .string as-is; .bool/.int/.double stringified; .null → nil |
intValue | Int? | .int as-is; .double truncated; .bool → 1/0; .string parsed; otherwise nil |
doubleValue | Double? | .double as-is; .int widened; .string parsed; otherwise nil |
The convenience reads apply the same coercion, so a flag stored as "42" still reads as 42 through getInt.
Warning:
palbase flags addaccepts--type json, and a JSON object or array has noFlagValuecase. The decoder rejects it, and because the snapshot is decoded as one document, a single JSON-valued flag makes the whole snapshot fail to decode — silently. The SDK keeps serving whatever it last held and every read falls back to its default, with no error anywhere. Do not declarejsonflags on an Environment an iOS app reads.
Observing changes
Three ways to react to a flag change — pick the one that matches your context.
SwiftUI
Read the flag in body, as in the quick example. The backing store is @Observable with per-key tracking: a view re-renders only when a flag it actually read changes. A sync tick that brings no changes invalidates nothing.
Note:
all()is the exception — it binds the view to the whole snapshot, so a view readingall()re-renders on any flag change, by design. Prefer single-key reads for per-flag granularity.
To hold the observable store directly — to pass into a view model, say — pb.flags.observable exposes it as a FlagsStore with the same reads:
@MainActor @Observable public final class FlagsStore {
public private(set) var snapshot: FlagsSnapshot
public func value(for key: String) -> FlagValue?
public func bool(_ key: String, default fallback: Bool) -> Bool
public func string(_ key: String, default fallback: String) -> String
public func int(_ key: String, default fallback: Int) -> Int
public func double(_ key: String, default fallback: Double) -> Double
public var all: [String: FlagValue] { get }
}
Reading through pb.flags.* in a view body is the supported reactive path; the store accessor exists for callers that need a concrete object.
Closure
let unsubscribe = pb.flags.onChange { snapshot in
print("flags updated to version \(snapshot.syncVersion)")
}
// later
unsubscribe()
onChange returns an Unsubscribe (@Sendable () -> Void). Hold it for as long as you want the observer alive, and call it to stop.
Async stream
for await snapshot in pb.flags.changes {
applyTheme(snapshot["theme"]?.stringValue ?? "light")
}
changes is an AsyncStream<FlagsSnapshot>; the current snapshot is yielded immediately on subscription, then one per change.
Both observation styles deliver a FlagsSnapshot — an immutable picture of every flag at one sync version:
public struct FlagsSnapshot: Sendable, Equatable {
public let values: [String: FlagValue] // resolved values by key
public let syncVersion: String // monotonic server version
public let sources: [String: String] // per-flag source, e.g. "override" / "default"
public static let empty
public subscript(_ key: String) -> FlagValue? // shorthand for values[key]
}
Control: refresh() and ready()
| Method | What it does |
|---|---|
func refresh() async | Force an immediate full re-fetch from the server |
func ready() async | Suspend until the first snapshot — disk or network — is available |
ready() is for a launch decision that should not act on a default:
.task {
await pb.flags.ready()
showOnboardingV2 = pb.flags.isEnabled("onboarding_v2")
}
On warm installs ready() resolves near-instantly from the disk cache; only a true first launch waits on the network.
How sync works
You manage none of this — it is the behaviour behind the reads.
-
Cold start. The last-known snapshot is loaded from disk immediately, so reads are correct before any network I/O.
-
Snapshot. An authoritative full snapshot is fetched from
GET /v1/user-flags/snapshot, sendingIf-None-Match: <syncVersion>so an unchanged server answers304and nothing is re-parsed. -
Delta polling. The SDK polls
GET /v1/user-flags/delta?since=<syncVersion>every 30 seconds. -
Realtime fast path. Flags LISTENS to the shared realtime socket — it is not built into it. While every flags channel the SDK wants has been joined (the server acknowledged the join, not merely that a socket is open), the 30-second poll is suspended: the platform pushes the change and the SDK applies it immediately. The moment any of those channels stops carrying — refused, offline, or newly wanted and not yet acknowledged — polling resumes as the offline fallback. Flag flips land in seconds when the app is live, and nothing is missed when it is not.
The distinction is not pedantry. Before 0.58.0 the poll was suspended on the SOCKET being up, and a socket whose channels the server had refused looked exactly like a working one: no push, no poll, and nothing anywhere saying so. What the poll waits on now is the acknowledgement, per channel.
-
Auth transitions. Sign-in, sign-out and token refresh each trigger a full re-snapshot, so per-user overrides appear the moment the user signs in and disappear when they sign out. See Auth.
-
Backgrounding. Polling pauses when the app enters the background and resumes, with an immediate refresh, on foreground.
A failed fetch is never destructive: a transport failure, a 304, or a snapshot that will not decode all leave the existing cache in place.
Setting overrides
There is no override-writing API on iOS — a client cannot decide its own flags. An override is written on the server, and the sync engine above delivers it.
From the CLI, against the linked Environment:
palbase flags user set <user-id> new_composer --type boolean --value true
palbase flags user list <user-id>
palbase flags user unset <user-id> new_composer
Note:
--typesays how to serialise what you typed; it is not sent to the server. The declared type lives on the flag definition, and the stack answers400 type_mismatchwhen they disagree and404 key_not_foundfor a key that was never declared withpalbase flags add.
From backend code:
// In your backend (not on iOS):
import { Controller, Post, User, Flags } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
@Controller("/beta")
export class BetaController {
@Post("/join")
async join(@User() user: UserT): Promise<void> {
await Flags.$asService().setOverrideForUser(user.id, "new_composer", true);
}
}
Flags.$asService() also has clearOverrideForUser, which is how a "leave the beta" route puts the user back on the Environment default. The signed-in device picks either change up through the realtime push, or the next 30-second poll, and any SwiftUI view reading new_composer re-renders.
Flags the platform owns
Keys beginning palbase. are managed by the platform, and only two exist. Any other palbase.* key is refused with 400 reserved_key_prefix, so the namespace cannot be extended from your side.
| Key | Type | What it does |
|---|---|---|
palbase.debug_console | bool | Gates pb.debug.isEnabled, pb.debug.view and startLiveSession() — see Debug Console |
palbase.force_update | bool | The platform's kill switch for builds you no longer support. This SDK does not act on it: read it like any other flag with pb.flags.isEnabled("palbase.force_update") and decide what to show |
A managed key is a real row on the Environment, not an override-only key. palbase flags add cannot create one — it refuses a dot outright — so declare it from Studio or with a direct PUT /v1/management/flags/{key}. Neither key exists on a fresh Environment, and a per-user override of a key the Environment has never declared answers 404 key_not_found: declare the row first, then open it per user.
Warning:
palbase.debug_consolefails closed on every Environment. Absent,false, not yet synced, a failed fetch, or an SDK that was never configured all readfalse. Only an affirmativetrueopens the console, and you turn it on as a per-user override — there is no Environment where it is on by default, and no build flag that opens it.
Reserved namespace
flags is one of nine namespaces codegen refuses to emit, so a generated method can never collide with pb.flags. The full set is auth, analytics, flags, realtime, notifications, perf, messaging, purchases and debug. A backend controller whose namespace lands on one of them is skipped with a visible comment in the generated file:
// codegen: skipped reserved namespace "flags" (SDK-owned)
See Codegen for how a namespace is derived in the first place.
Related
- Stack Settings —
palbase flags add,flags remove, and per-user overrides from the CLI. - Flags (backend) — reading flags and writing overrides from a handler.
- Flags (web) — the same values in the web SDK.
- Realtime — the shared socket that powers the flags fast path.
- Debug Console — what
palbase.debug_consoleunlocks.