Serverless client
Evaluate flags in your code on Cloudflare Workers, Vercel, AWS Lambda, and other request-scoped runtimes with @toggld/js/serverless.
@toggld/js/serverless evaluates flags in your code on runtimes that run code only while they handle a request: Cloudflare Workers, Vercel Functions, AWS Lambda, and others. To find the right client for your runtime, see Choosing a runtime and mode.
The client keeps the flag snapshot of its environment in memory, and evaluates in your code with the same engine as the Toggld API. It holds no socket, timer, or background loop. A request refreshes the snapshot when the snapshot gets old, so the docs call this strategy on-demand. It has no mode option.
- No billable requests. Evaluations run in your code. Snapshot refreshes are definition sync, which is fair use. See Plans and limits.
- Fast. Most evaluations take microseconds. Only the first request of an isolate or process waits for the snapshot.
- Works without Node.js APIs. The entry imports no
node:modules, so it runs on Cloudflare Workers without thenodejs_compatflag.
Install
pnpm add @toggld/jsThe serverless client needs a secret key that starts with ffk_, because it downloads your targeting rules. Store it as a secret of your platform, such as wrangler secret put TOGGLD_SDK_KEY.
Cloudflare Workers
Create one client per isolate, and one request view per request with forRequest(ctx):
import { createServerlessClient, type ServerlessClient } from "@toggld/js/serverless";
let flags: ServerlessClient | undefined;
export default {
async fetch(request, env, ctx): Promise<Response> {
flags ??= createServerlessClient({ sdkKey: env.TOGGLD_SDK_KEY, endpoint: "https://api.toggld.io" });
const f = flags.forRequest(ctx);
const on = await f.getBooleanValue("new-checkout", false, { targetingKey: "user-123" });
return new Response(on ? "new checkout" : "old checkout");
},
} satisfies ExportedHandler<Env>;createServerlessClient makes no request, so you can also call it in module scope, for example with import { env } from "cloudflare:workers".
With ctx, the client refreshes a stale snapshot and sends telemetry with ctx.waitUntil, after your response.
Vercel and Next.js
On Vercel Edge Functions and Next.js middleware, pass an object with waitUntil:
import { waitUntil } from "@vercel/functions";
import { createServerlessClient } from "@toggld/js/serverless";
const flags = createServerlessClient({
sdkKey: process.env.TOGGLD_SDK_KEY!,
endpoint: process.env.TOGGLD_API_URL!,
});
export async function middleware(request: Request) {
const f = flags.forRequest({ waitUntil });
const on = await f.getBooleanValue("new-checkout", false, { targetingKey: "user-123" });
// …
}AWS Lambda and Node.js functions
On AWS Lambda, Vercel Node.js functions, and Netlify Functions, call forRequest() without a context:
import { createServerlessClient } from "@toggld/js/serverless";
const flags = createServerlessClient({
sdkKey: process.env.TOGGLD_SDK_KEY!,
endpoint: process.env.TOGGLD_API_URL!,
});
export const handler = async (event: { userId: string }) => {
const f = flags.forRequest();
const on = await f.getBooleanValue("new-checkout", false, { targetingKey: event.userId });
return { statusCode: 200, body: on ? "new checkout" : "old checkout" };
};These platforms freeze the process as soon as the handler returns, so work that runs after the response is not guaranteed to finish. Without a context, the client does all of its work inside the call:
- A stale snapshot is refreshed before the evaluation returns. The wait is bounded by
timeoutMs. If the refresh fails, the client uses the stale snapshot. - When telemetry is due, it is sent before the evaluation returns, with a limit of 1 second. A failed upload never fails the evaluation. The counts are sent with the next upload.
An object without a waitUntil method, such as the Lambda context, counts as no context.
Freshness
Each request checks the age of the snapshot in memory:
| Snapshot age | With waitUntil | Without waitUntil |
|---|---|---|
Younger than maxAgeMs (default 30 seconds) | Use it. | Use it. |
Younger than maxStaleMs (default 5 minutes) | Use it, and refresh it after the response. Only one refresh runs per isolate at a time. | Refresh it, then evaluate. |
No snapshot yet, or older than maxStaleMs | Refresh it, then evaluate. | Refresh it, then evaluate. |
- A refresh is bounded by
timeoutMs(default 1500 ms). - If a refresh fails, the client uses the snapshot that it has, however old. An unreachable API never turns your flags off. Without any snapshot, evaluations return your fallback value with
errorCode: "PROVIDER_NOT_READY", anderrorMessagenames the last error. After a failure, the client waits before it tries again, from 1 second up to 30 seconds, or for theRetry-Aftertime of a429. - An unchanged snapshot costs little. A refresh sends
If-None-Matchwith the version that the client has. If nothing changed, the API answers304 Not Modifiedwith no body. - A newer snapshot is never replaced by an older one. The API's cache can serve an older snapshot than one that the client already has. The client keeps the newer one.
- All evaluations of one request view use one check. A request that evaluates ten flags checks the snapshot age once.
A change reaches an isolate or process within about maxAgeMs plus up to 60 seconds of API cache age, once it receives a request. Each isolate or process has its own snapshot, so two of them can briefly serve different values. A new isolate or process waits for its first snapshot. If many requests reach a new isolate at once, each of them can download the snapshot once.
Evaluate flags
The request view has the same evaluation methods as the main client, and they return the same details, reasons, and error codes. All of them are async.
const f = flags.forRequest(ctx);
const theme = await f.getStringValue("checkout-theme", "light", { targetingKey: "user-123" });
const details = await f.getBooleanDetails("new-checkout", false, { targetingKey: "user-123" });
const all = await f.evaluateAll({ targetingKey: "user-123" });Use a request view only inside the request that created it. On Cloudflare Workers, I/O that one request starts cannot be used by another request, so the client never shares a pending download between requests.
Effect API
@toggld/js/serverless/effect provides the serverless client as the same ToggldClient service that @toggld/js/effect provides. Program code that uses yield* ToggldClient works with either layer. Install effect (version 4) next to the SDK. This entry imports no node: modules either.
Build the layer once per isolate or process, for example in a module-scope ManagedRuntime. Each evaluation looks for the optional WaitUntil service. Provide it per request with withWaitUntil(ctx). Without it, the client works as without a context: it refreshes a stale snapshot inline.
import { Effect, ManagedRuntime } from "effect";
import { ToggldClient, layer, withWaitUntil } from "@toggld/js/serverless/effect";
const runtime = ManagedRuntime.make(
layer({ sdkKey: "ffk_YOUR_SECRET_KEY", endpoint: "https://api.toggld.io" }),
);
const checkout = Effect.gen(function* () {
const flags = yield* ToggldClient;
const on = yield* flags.getBooleanValue("new-checkout", false, { targetingKey: "user-123" });
return new Response(on ? "new checkout" : "old checkout");
});
export default {
fetch: (request, env, ctx) => runtime.runPromise(checkout.pipe(withWaitUntil(ctx))),
} satisfies ExportedHandler<Env>;On AWS Lambda, run the program without withWaitUntil.
withWaitUntil(ctx)callsctx.waitUntilas a method ofctx, as Workers requires.waitUntilLayer(ctx)gives the same service as a layer.readyruns the freshness check that an evaluation would run, and completes even if the refresh fails.flushTelemetrysends buffered counts within the current request.- If you interrupt an evaluation that waits for a refresh, the refresh request is cancelled. A refresh that runs in
waitUntilis not cancelled. - Closing the layer's scope sends the buffered telemetry, bounded by
timeoutMs. The client holds no other resource. - Options that can never work, such as a public token, make the layer fail with a
TypeError.
Options
| Option | Default | Description |
|---|---|---|
sdkKey | Required | A secret key (ffk_). A public token (ffp_) throws a TypeError. |
endpoint | Required | The API URL, such as https://api.toggld.io. |
maxAgeMs | 30000 | How long a snapshot is used before a request refreshes it. |
maxStaleMs | 300000 | How old a snapshot can be before a request waits for a refresh. |
timeoutMs | 1500 | The timeout of each request to the API. |
defaultContext | None | Attributes merged into every evaluation context. The per-call context wins. |
telemetry | On | false turns telemetry off. { flushIntervalMs } sets how often counts are sent (default 30 seconds). |
fetch | Global fetch | A custom fetch implementation. |
Telemetry
The client counts evaluations in memory and sends the counts to the API at most once per flushIntervalMs, from a request: with waitUntil after the response, without it inside the call. The counts feed Analytics. Counts that are not sent yet are lost if the platform discards the isolate or process. To send them at a known point, such as the end of a handler, call await f.flushTelemetry().
Limits
- One snapshot per isolate or process. Memory use grows with the size of your flag set, as in sync mode.
- No live updates. Changes arrive on the next refresh, not as pushes. For live updates, use sync mode on a long-lived server.
- Secret keys only. For a public token, use
@toggld/browserin the browser, or remote mode on the server. - No flag files. To evaluate from a file, use the main entry. See Flag files.