Skip to main content

October 29, 2026

Read env vars by name, never by spread

Spreading process.env silently dropped Convex kill switches. Read each variable by name from a known list, and audit prod before a fix goes live.

Sander Korf4 min read
convexnextjstypescriptfeature-flags

Someone using a voice-guided self-help web app talks out loud with a spoken AI guide that walks them through a structured session, step by step. The team ships behaviour changes to that guide behind per-deployment feature switches (settings you can flip without redeploying code, like a kill switch that turns a new prompt path off in production). Each deployment is a running copy of the backend with its own env settings, set with npx convex env set on Convex.

The backend composed five CHAT_VARIANT_* switches with { ...defaults, ...process.env }. Any switch set to off in deployment env was ignored, so turning off the re-ask behaviour, for example, would still leave the guide re-asking. Nothing errored, and unit tests passed because they handed in a plain object. In the Convex runtime, spread (...) over process.env produced an empty object: process.env.CHAT_VARIANT_X by name worked, but Object.keys(process.env) and spread saw nothing. Every value an operator set in prod still fell back to the default on.

Set VARIANT_REASK to off, keep Spread process.env selected on the managed runtime, and watch the active behaviours: nothing changes. Switch to Read each name and the re-ask path drops out. Tick Audit first, press Deploy the fix, and see stale prod values (off, ON, true) start counting the moment the read is corrected.

Simulation

Spread vs read-by-name for feature switches

Simulated Convex-style env: literal lookups work, spread does not. No real secrets.

What the code saw

Spread saw {} (managed runtime hides enumerable env)

VARIANT_REASK
on
VARIANT_REMEMBER
on
VARIANT_SUMMARY
on

Guide behaviours active

Active count

3 / 3

  • Re-ask clarifying questions
  • Recall earlier answers
  • Close with a spoken recap

Warning: VARIANT_REASK is set to off in deployment env, but spread on a managed runtime still leaves Re-ask clarifying questions active.

Runtime
Read strategy

Deployment env (simulated)

Read the known names, not the env object

An environment variable is a named string the platform injects into your process (CHAT_VARIANT_REASK=off). The reusable rule: read every variable by its literal name from a known list, never by enumerating process.env, and when you fix a read that was silently broken, audit production values first because they are about to start counting.

export function variantsNow(env: Readonly<Record<string, string | undefined>>): Required<ChatAgentVariants> {
  return chatVariantsFromEnv(
    Object.fromEntries(
      Object.entries(chatVariantDefaults).map(([name, fallback]) => [name, env[name] ?? fallback]),
    ),
  )
}
const variants = variantsNow(process.env)

We only iterate chatVariantDefaults, then look up env[name]. Anything that is not exactly on counts as off. That matches how operators think about kill switches and avoids surprise true or ON strings.

The second trap is dormant config. While spread returned {}, prod could hold CHAT_VARIANT_REASK=off without effect until the read was fixed. The fix shipped with a pre-deploy step: list the prod values (npx convex env list --prod | grep CHAT_VARIANT) and decide on each stale one before deploying, because anything not exactly on (off, empty, ON, true) turns that behaviour off on the next reply.

Next.js public flags use the same literal rule

The same pull request tightened the Next.js side. NEXT_PUBLIC_FLOW_ENGINE is read only as process.env.NEXT_PUBLIC_FLOW_ENGINE inside one module, and a test greps the repo to keep that literal single-sourced.

Next.js documents environment variables for the browser by saying it replaces references to process.env.[variable] with a hard-coded value at build time, and that dynamic lookups will not be inlined, including process.env[varName] and reading from a const env = process.env object. In Convex the spread came back empty; I didn't dig into why, and the rule doesn't depend on it. Treat any bundled or managed runtime the same way: do not rely on spreading or iterating process.env.

For remote-template kill switches that default to on at both ends, see A kill switch must default to on, at both ends. That post is about sync semantics; this one is about whether your reader can see the env at all.

When this doesn't matter

On a plain long-running Node server you control, reading two vars by name already is fine. The cliff shows up in serverless, edge, Next client bundles, and Convex actions where the runtime may not expose an enumerable env object. If your config helper spreads process.env, add one integration test that sets a non-default value and assert the behaviour actually changes.

Happy coding!

Sander