Skip to content
NanoFlags
Start free

SDKs

JavaScript SDK

Flags are evaluated locally and synchronously against an in-memory configuration bundle. The network is used only in the background: to load the bundle, to learn about new revisions, and to send sampled telemetry. An accessor never performs I/O and never throws.

Overview

sh
npm install @nanoflags/sdk
ImportUse
@nanoflags/sdkNode, browsers and edge runtimes: createClient, types.
@nanoflags/sdk/reactReact bindings: NanoFlagsProvider, useFlag, useFlagsStatus.
@nanoflags/sdk/nodeTest-session propagation with AsyncLocalStorage.

ESM with TypeScript types; the only runtime dependency is the NanoFlags evaluation engine. React is an optional peer dependency (18 or newer).

createClient options

ts
import { createClient } from "@nanoflags/sdk";

const flags = createClient({
  sdkKey: process.env.NANOFLAGS_SDK_KEY!,
  baseUrl: "https://edge.nanoflags.io",
  service: "checkout-api",
  pollIntervalMs: 30_000,
  streaming: true,
  live: true,
  events: { sampleRate: 0.1 },
});
OptionDefaultDescription
sdkKeyrequirednf_srv_… on servers, nf_pub_… in browsers. Never ship a server key to a browser.
baseUrlrequiredEdge URL, https://edge.nanoflags.io.
service"unknown"Name this process reports under ("which revision is this service running").
pollIntervalMs30000Conditional poll interval (± 20 % jitter). 0 disables polling, including the safety poll.
streamingtrueReceive updates without waiting for a poll (live socket, else SSE). false = polling only.
livetruefalse skips the WebSocket and uses SSE + polling.
livePollIntervalMs300000Safety poll interval while the live socket is healthy.
webSocketglobal WebSocketWebSocket constructor, e.g. require("ws") on Node older than 22. Without one the SDK uses SSE + polling.
bootstrapnoneA bundle obtained elsewhere (embedded at build time, or passed from your server for SSR). Trusted as given.
storeIndexedDB in browsers, none elsewhereAny async string store { get(), set(v) }; false disables persistence.
events{}enabled (true), sampleRate (0.1), flushIntervalMs (10000), reportIntervalMs (60000), redact(ctx). false disables telemetry. See Telemetry.
verifyon for https://edge.nanoflags.io (from 0.4.0), off elsewhere{ publicKeys: JsonWebKey[] | "fetch", require?: boolean }, or false to turn verification off. See Signature verification.
fetchglobal fetchCustom fetch implementation.

Methods and properties

MemberDescription
ready(): Promise<void>Resolves when a usable bundle is loaded (network, store or bootstrap), or when the first load attempt finished without one. Never rejects: check status.
boolean · string · number · json (key, fallback, ctx?)The flag's value, or fallback. Synchronous.
evaluate(key, fallback, ctx?)The full result: value, reason, ruleIndex, ruleId, inRollout, bucket, revision, trace. No type check.
status"not-ready" · "ready" · "stale", see Status.
revisionRevision of the bundle in use; null without one.
transportHow updates reach the client now: "live" (WebSocket open), "stream" (SSE), "poll".
on("update" | "error", cb)Subscribe; returns an unsubscribe function.
session(token, { service? })Evaluator for a test session. Never rejects.
useSession(token | null) · sessionHeaders()Browser only: join or leave a test session for this tab; headers for downstream calls.
refresh()Revalidate now. Resolves when finished, successfully or not.
flush()Send queued telemetry now.
close()Stop timers, the live connection and telemetry. Evaluations keep working on the last bundle.

The context is a plain object of JSON values. key is the stable identity used for rollout bucketing.

Status

status describes the bundle, not individual flags:

  • not-ready: no bundle. Every accessor returns its fallback.
  • ready: the bundle was confirmed by the edge on the last attempt (or supplied as bootstrap and no attempt has failed yet).
  • stale: a bundle is in use but is not known to be current: restored from the store and not yet revalidated, the last revalidation failed (offline, edge error, rejected key), or the edge served its last-known-good copy. Evaluations continue on that bundle while the SDK retries (after a failure: 1 s, 2 s, 4 s … up to the poll interval).

The SDK never discards a working bundle because of an error and never replaces a bundle with an older one. If a key is revoked, the client emits error (unauthorized) and keeps evaluating the last bundle as stale: revocation stops updates, it does not erase configuration that already reached a process.

Events

update

ts
flags.on("update", (e) => {
  e.revision;          // new revision
  e.previousRevision;  // number | null
  e.checksum;          // string | null
  e.source;            // "network" | "store" | "bootstrap"
  e.transport;         // "live" (pushed) | "stream" (fetched after an SSE notification) | "poll" (everything else)
  e.latencyMs;         // commit → this event, only for pushed revisions with a commit time; otherwise null
});

latencyMs compares the database clock with the local clock, so it is only as accurate as the two clocks agree. Current measured propagation is on the status page.

error

Receives a NanoFlagsError with a code (and httpStatus where relevant). Errors never interrupt evaluation.

codeMeaning
networkThe edge could not be reached.
unauthorizedThe key was rejected (unknown or revoked, HTTP 401/403).
httpAnother unexpected HTTP status.
invalid_bundleThe response was not a usable bundle.
storeReading or writing the persistence store failed.
configInvalid options, for example a private JWK passed to verify.
signatureA bundle failed signature verification and was not used.
sessionA test-session lookup failed.
engineA bundle needs a newer evaluation engine than this SDK has. It was not used; upgrade the SDK.
environment_deletedThe environment of this key was deleted. See Deleted environments.

Fallbacks

The fallback argument is returned when NanoFlags cannot answer:

Situationreason
No bundle loaded yet (not-ready)flag_not_found
The flag is not in the bundle (unknown, archived, or not client-exposed with a client key)flag_not_found
The flag's value has another type than the accessor (boolean() on a string flag)error
The configuration is malformederror

A disabled flag is not a fallback case: it returns the flag's configured off value (reason off).

Transports

  1. Live push (default): one WebSocket to GET /v1/live. When a revision is published, the edge sends a rev message carrying the signed bundle itself, so it is applied without another request; on (re)connect a hello message carries the current revision. The key travels in the WebSocket subprotocol list, never in the URL. The SDK pings about every 30 s, replaces the socket about every 50 minutes (so the key is validated again), and reconnects with jittered exponential backoff (1 s, 2 s, 4 s … capped at 5 minutes, each × 0.5–1.5). While the socket is healthy, SSE is not opened and polling drops to livePollIntervalMs.
  2. Stream (fallback): when the socket cannot be established (no WebSocket in the runtime, a proxy that blocks upgrades, or the edge answering 503 live_unavailable), the SDK opens a server-sent-events stream carrying only { revision, checksum } and fetches the bundle conditionally. Client keys use EventSource with ?key=; everything else a fetch stream with the Authorization header. The SDK keeps retrying the live socket and closes the stream once a socket opens.
  3. Polling (safety net): a conditional GET /v1/bundle every pollIntervalMs ± 20 %. Hidden browser tabs do not poll and catch up when they become visible.

live: false keeps stream + polling; streaming: false leaves polling only. A new bundle replaces the old one in a single assignment, so an evaluation sees one revision in full, never a mixture. The SDK never moves to an older revision than one it was pushed.

Signature verification

The SDK checks the Ed25519 signature before a new bundle replaces the one in use, for fetched and pushed bundles alike.

  • Default from 0.4.0: with baseUrl set to https://edge.nanoflags.io, the SDK verifies every bundle against the NanoFlags signing keys it ships with (and loads GET /v1/keys once when a bundle names a key id it does not know) and rejects unsigned bundles. With any other baseUrl, nothing is verified unless you configure verify.
  • GET /v1/keys returns a JWKS-style list of public keys, each with a kid and a status: active (signs new bundles) or retiring (still valid for bundles signed before a rotation). See Signatures.
  • Verification needs Ed25519 in WebCrypto: Node.js 20+, current browsers, Cloudflare Workers, Deno, Bun. On a runtime without it, set verify: false; otherwise signed bundles are rejected because they cannot be checked.

To pin your own copy of the keys (for example, to verify bundles fetched through your own proxy or cache):

ts
const flags = createClient({
  sdkKey, baseUrl,
  verify: {
    publicKeys: [{ kty: "OKP", crv: "Ed25519", x: "wQu1EsaE-yTjqpUbibGyBrajy3IiRb7LyaiqeYRxG4U", kid: "k1" }],
    require: true,
  },
});
  • Bad signature: the update is rejected, the previous bundle stays in use, status does not change, and error fires with signature. A pushed bundle that does not verify also makes the SDK ask the edge over HTTP what is current.
  • Unsigned bundles (X-NanoFlags-Signed: false, served briefly while signing catches up after a publish) are refused with the default settings on the NanoFlags edge; the SDK fetches again shortly. With a custom verify they are accepted unless require: true. Without require, someone who can alter responses can also strip the signature.
  • publicKeys: "fetch" loads keys once from GET /v1/keys (reloaded at most once a minute when a bundle names an unknown key id). This detects corruption and tampering in storage and caches, but the keys are only as trustworthy as the edge that served them. Pin the keys in your build if the edge is in your threat model.
  • After the first verified bundle the client remembers the environment id and rejects bundles signed for another environment.
  • Only public keys are accepted; a JWK with a private part (d) is ignored and reported as a config error.
  • Not covered: test-session bundles are not signed, and a signature says nothing about freshness; it does not stop someone from withholding updates.

Engine version

Every bundle names the evaluation engine version it was written for (engine). An SDK refuses a bundle from a newer engine than it supports: it keeps its previous configuration, does not change status because of it, and emits error with code engine. Upgrade the SDK to receive that configuration.

Deleted environments

When an environment or its project is deleted, the edge closes live connections with WebSocket close code 4004 and stops accepting the environment's keys within about a minute. The SDK then stops reconnecting, keeps evaluating its last configuration with status stale, and emits error with code environment_deleted. Remove or replace the key in your application.

Persistence and bootstrap

  • Browser: IndexedDB when available (private modes and blocked storage behave like an empty store). Node and edge runtimes: no persistence by default, which is safe for read-only and ephemeral file systems.
  • store accepts any async string store; false disables it.
  • The stored value contains the bundle (flag rules), never the SDK key. It is bound to the key and baseUrl, so a copy written for another environment is ignored. A restored bundle starts as stale until revalidated.
  • With verify, a stored bundle is kept with its signature and verified again when restored; with require, an unsigned stored copy is ignored. With publicKeys: "fetch" an offline start cannot verify (keys not loaded), so the stored copy is not restored; pinned keys do not have this limitation.
  • bootstrap is trusted as given. For server rendering, pass the bundle you evaluated with on the server so the first client render matches.
custom store (Node)
import { readFile, writeFile } from "node:fs/promises";

const flags = createClient({
  sdkKey, baseUrl,
  store: {
    get: () => readFile("/var/cache/nanoflags.json", "utf8").catch(() => null),
    set: (v) => writeFile("/var/cache/nanoflags.json", v),
  },
});

Telemetry

Every flushIntervalMs (10 s) while evaluations are queued, the SDK sends one POST /v1/events; while nothing is queued it sends the service report alone every reportIntervalMs (60 s). A request carries a service report ({ service, sdk: "js/<version>", revision }) and up to 50 sampled evaluation events (sampleRate, default 10 %): flag key, value, reason, rule index, revision, time, and a redacted context.

Default redaction sends no attribute values
For each sampled event the SDK sends only the names of the context attributes that the evaluated flag's rules reference and that were present, each mapped to the value's type, for example { "region": "string", "signedIn": "boolean", "key": "string" }. Attributes no rule uses are dropped.
  • events.redact(ctx) replaces the default; whatever it returns is sent as is.
  • Flag values are sent (strings above 1 KB are truncated, larger JSON values are replaced by null). Do not put secrets in flag values.
  • Telemetry is off the evaluation path: events go to a bounded in-memory queue (500 entries, then dropped). Failed batches are dropped, never retried. events: false disables everything, including service reports.

What a reported signal proves, and what it does not

  • A service report proves that a process using this SDK key, naming itself service, had revision N loaded when it reported. It does not prove that every instance is on that revision, that the service is healthy, or that it is still running: it is "last seen". A missing report proves nothing: telemetry may be disabled, blocked, sampled out or dropped.
  • service is self-declared and authenticated only by the SDK key. With a client key, anyone can send reports and events. Treat them as observability, not as an audit trail.
  • A sampled event proves that an evaluation with that result happened in some process. It does not prove that the user saw the feature, and the absence of events does not prove a flag is unused: with 10 % sampling, rarely evaluated flags can go unseen for a long time.
  • Counts are estimates: sampling is per evaluation, events are dropped under back-pressure, network failure or the daily cap, and React components may evaluate once per relevant change rather than once per user action.