Skip to content

Node SDK

@lucerna-dev/gates-node checks flags, kill switches and experiments in your server. It downloads your rules once, keeps them fresh in the background, and answers every check from memory.

Prefer asking the server per call instead of running a background refresh? See remote evaluation.

Runs on Node 18+, Bun, Deno and edge runtimes.

Setup

Create one client when your process starts and share it:

ts
import { createGates } from "@lucerna-dev/gates-node";

export const gates = createGates({
  serverKey: process.env.LUCERNA_GATES_KEY!,
  onError: (error) => logger.warn("gates refresh failed", error),
});

await gates.ready();

Options

OptionDefaultWhat it does
serverKeyYour server key (ck_srv_…). Required.
mode"poll" | "remote""remote" skips the rules download entirely — see remote evaluation.
remoteCacheTtlMs0How long *Async answers are reused before asking again. Kill switches never reuse longer than 10s.
refreshIntervalMs10000How often rules refresh. Leave it — 10s is what makes kill switches fast.
requestTimeoutMs5000Timeout per rules download.
onErrorCalled when a refresh fails. Wire it to your logging.
fetchglobal fetchCustom transport, mostly for tests.
trackExposurestrueReport experiment exposures to Lucerna. Turn off in tests.

Checking gates

All checks are instant and never throw:

MethodReturnsIf unknown or not loaded
gates.flag(key, user?)booleanfalse
gates.experiment(key, user?)variant name or nullnull
gates.switch(key)booleanfalse means killedtrue (not killed)
gates.evaluate(user?)every decision at onceempty set
gates.inspect(key, user?)decision + explanation{ key, loaded }

user is { userId?, traits? } — pass identity.current() from @lucerna-dev/identity, or a plain object. Traits are strings — write { seats: "12" }, not { seats: 12 }.

One identity per request

On a server, create the identity inside the request handler. A module-level identity is shared by every request — everyone gets targeted as the same user.

Why did I get this answer?

inspect() shows the decision and how it was made — useful in logs and support tooling:

ts
gates.inspect("new_billing", { userId: "u_42" });
// {
//   key: "new_billing",
//   loaded: true,
//   flag: {
//     decision: { on: false, reason: "rollout" },
//     trace: [
//       { step: "enabled", detail: "flag is on in this environment" },
//       { step: "condition", detail: 'plan is "pro": no match' },
//       { step: "rollout", detail: "bucket 7412 >= threshold 2500 (basis points of 10000)" },
//     ],
//   },
// }

Experiment results

When your code reads a variant with experiment(), the SDK tells Lucerna that user saw the test — that's how the results page counts who was in each variant. You don't have to do anything.

It's built to stay out of your way:

  • Recorded only when you read a variant — never on refreshes.
  • Sent in the background in batches. Reads stay instant.
  • Each user counts once. Re-sending is harmless.
  • If the network is down, events are dropped, never queued forever.

Set trackExposures: false to turn it off (in tests, for example).

ready() and close()

  • await gates.ready() — resolves once your rules are loaded. Rejects if the server key is wrong, so a bad deploy fails loudly.
  • gates.close() — stops the background refresh and sends any queued exposures. Call it on shutdown; await it if you want that last batch delivered before the process exits.

When things go wrong

The SDK never crashes your app:

  • Rules haven't loaded yet (or the key is wrong): checks answer safe defaults — flags false, experiments null, kill switches not killed.
  • Lucerna becomes unreachable later: checks keep answering from the last downloaded rules, and onError fires on each failed refresh.

Wire onError to your logging so you notice.

Remote evaluation

Every check above answers from rules held in memory. The same client also carries async twins that ask the Lucerna server per call — no rules download, no background refresh:

MethodReturnsOn any failure
gates.flagAsync(key, user?)Promise<boolean>false
gates.experimentAsync(key, user?)variant name or nullnull
gates.switchAsync(key)Promise<boolean>false means killedtrue (not killed)
gates.evaluateAsync(user?)every decision at onceempty set

They keep the same promise as the sync checks: they never reject. A timeout, an unknown key or an unreachable server resolves the safe default and fires onError. Each call makes at most two attempts, so a bad network can't stack up latency.

Good to know:

  • experimentAsync records the exposure on the server during the same request — there is nothing to queue or flush. evaluateAsync never records exposures, same as evaluate().
  • switchAsync remembers its answer for up to 10 seconds so a hot path doesn't ask on every request — the same 10-second budget kill switches always have.
  • Set remoteCacheTtlMs to reuse flag and experiment answers on a warm process. Flags change rarely; many servers can afford 30–60s. Kill switches stay capped at 10s no matter what you set.

Don't forget the await

gates.flagAsync("x") without await is a Promise — always truthy, so if (gates.flagAsync("x")) takes the branch for everyone.

mode: "remote" — no poller at all

If the process should never download rules, construct with mode: "remote":

ts
const gates = createGates({
  serverKey: process.env.LUCERNA_GATES_KEY!,
  mode: "remote",
});

Nothing runs in the background and there is nothing to close. The sync checks (flag, switch, …) answer safe defaults in this mode — use the *Async methods; a one-time onError warning reminds you if you mix them up. Your ck_srv_… server key works in both modes.

Serverless

Lambda, Cloud Run, Vercel, Cloudflare Workers — use remote evaluation: construct once in module scope, nothing runs in the background, nothing to close.

ts
const gates = createGates({ serverKey: process.env.LUCERNA_GATES_KEY!, mode: "remote" });

export async function handler(event: CheckoutEvent) {
  if (await gates.flagAsync("new_billing", { userId: event.userId })) {
    // ...
  }
}

Need more than one decision? One round trip gets them all:

ts
const decisions = await gates.evaluateAsync({ userId: event.userId });

if (decisions.kills.payments === false) return maintenanceResponse();
return render({ newBilling: decisions.flags.new_billing === true });

Prefer the poller anyway — say, a job that checks many gates for many users? Create the client inside the unit of work and close it after:

ts
import { createGates, type GatesClient } from "@lucerna-dev/gates-node";

export async function withGates<T>(
  serverKey: string,
  work: (gates: GatesClient) => Promise<T>,
): Promise<T> {
  const gates = createGates({ serverKey });
  try {
    await gates.ready();
    return await work(gates);
  } finally {
    await gates.close(); // also delivers any queued exposures
  }
}

This downloads the rules on every call — fine for background jobs, wrong for a busy server. Busy servers use the shared client above.

Full example

ts
import { createGates } from "@lucerna-dev/gates-node";
import { createIdentity } from "@lucerna-dev/identity";

const gates = createGates({
  serverKey: process.env.LUCERNA_GATES_KEY!,
  onError: (error) => logger.warn({ err: error }, "gates refresh"),
});

await gates.ready();

app.get("/checkout", (req, res) => {
  const identity = createIdentity({ apiKey: process.env.LUCERNA_SERVER_KEY! });
  identity.identify({ userId: req.user.id, traits: { plan: req.user.plan } });
  const user = identity.current();

  if (!gates.switch("payments")) {
    return res.status(503).json({ error: "payments_paused" });
  }

  const variant = gates.experiment("checkout_test", user) ?? "control";
  const showNewBilling = gates.flag("new_billing", user);

  res.render("checkout", { variant, showNewBilling });
});

process.on("SIGTERM", () => gates.close());

See evaluation semantics for how decisions are made.

Lucerna Developer Docs