open atlas
↑ Back to track
Node.js, zero to senior NODE · 08 · 03

Configuration and runtime flags: env, fail-fast, and heap limits

Read config once at boot into a frozen, schema-validated object and crash on a bad var before serving — and size the V8 old-space heap to ~75% of the container limit, because a non-cgroup-aware Node grows past the cap and gets OOMKilled at 137 with no JS stack.

NODE Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A staging deploy went green, the canary took traffic, and ninety seconds later every pod on the node flapped into CrashLoopBackOff. kubectl describe said Last State: Terminated, Reason: OOMKilled, Exit Code: 137. There was no heap-out-of-memory error in the logs, no JS stack, nothing — the process just vanished mid-request. The service hadn’t changed; the memory limit had, dropped from 1Gi to 512Mi in a cost-cutting pass. The Node inside still thought it owned the 32 GB host, let its heap drift past 512 MB, and the kernel’s OOM killer reaped it with a signal V8 never saw coming. The fix was one environment variable. The three days of blame aimed at “a memory leak” were aimed at the wrong layer entirely.

Config lives in the environment, not the code or the image

Ask yourself: if you had to promote today’s image to production with a different database URL, would you need a rebuild? If the answer is yes, you’re baking config into the image — and you’ve already broken the single-artifact promotion model.

The 12-factor rule is blunt: configuration — anything that varies between deploys — belongs in the environment, not in code and not baked into the image. The payoff is operational. You build one artifact, and the same image SHA promotes dev → staging → prod; only the env vars change. The moment a config.prod.json ships inside the image, you have lost that property: prod and staging are now different builds, “works in staging” stops meaning anything, and a config typo forces a full rebuild instead of a redeploy.

The mechanism that makes this safe is reading the environment once, at boot, into a typed and frozen object — never scattering process.env.X reads through the codebase. process.env values are always strings (or undefined); process.env.PORT is "3000", not 3000, and process.env.DEBUG is the string "false" — which is truthy. Centralizing the read is where you do the coercion exactly once, so the rest of the app consumes real numbers and booleans.

// config.js — read + coerce ONCE, then freeze
function num(v, name) {
  const n = Number(v);
  if (!Number.isFinite(n)) throw new Error(`env ${name} must be a number, got ${v}`);
  return n;
}

export const config = Object.freeze({
  nodeEnv: process.env.NODE_ENV ?? "development",
  port: num(process.env.PORT ?? "3000", "PORT"),
  databaseUrl: process.env.DATABASE_URL,           // validated below
  logLevel: process.env.LOG_LEVEL ?? "info",
  requestTimeoutMs: num(process.env.REQ_TIMEOUT_MS ?? "5000", "REQ_TIMEOUT_MS"),
});

The tradeoff is small and worth it: one boot-time read can’t pick up a var changed at runtime, so a config change needs a restart. In a 12-factor world that is exactly right — deploys are how config changes, and a frozen object means no code path can mutate config.port halfway through a request. The failure mode of the opposite habit is subtle: scattered process.env.RETRY_LIMIT reads, some defaulting to 3 and one to "3", drift apart over time and produce a config the team can no longer reason about from a single file.

Fail fast: validate the whole config at boot, crash on a bad var

Reading config isn’t enough; you have to validate the whole shape at startup and exit non-zero if anything is missing or malformedbefore the server binds a port. The anti-pattern is lazy: DATABASE_URL is undefined, nobody notices at boot, and the first request that touches the DB throws at 3am — a 500 to a user, a page to you, hours after the deploy that caused it. Boot validation converts that whole class of runtime 500s into a single, loud, immediate boot failure that the deploy pipeline catches while the canary is still ramping.

// config.js (top) — schema + fail-fast gate
import { z } from "zod";

const Schema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),               // required, no default → must be present
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  MAX_OLD_SPACE_MB: z.coerce.number().int().positive().optional(),
});

const parsed = Schema.safeParse(process.env);
if (!parsed.success) {
  console.error("✗ invalid configuration:\n" + z.prettifyError(parsed.error));
  process.exit(1);                              // die at boot, not at first request
}

export const config = Object.freeze(parsed.data);

z.coerce.number() solves the string problem at the schema layer, and a required z.string().url() with no default means a missing DATABASE_URL makes safeParse fail and the process exit(1) — the deploy goes red, traffic never shifts. The tradeoff: strict validation adds a few milliseconds to startup and forces you to declare every variable the app reads, which feels like bureaucracy until the day it saves you. The failure mode it kills is the worst kind — a deploy that looks healthy (the container started, the readiness probe hasn’t fired yet) but is one DB call away from a cascade of 500s. With fail-fast, an unhealthy config can’t reach the “serving” state at all.

Secrets are not config, and NODE_ENV is load-bearing

Two distinctions inside the environment matter for security and correctness. First, secrets are injected at runtime, never baked in. A DATABASE_URL with a password, an API key, a signing secret — these come from the orchestrator’s secret store (Kubernetes Secret, AWS Secrets Manager, Vault) mounted as env or a file at start. They are never committed and never written into an image layer, because image layers are cacheable, pullable, and effectively permanent: a secret baked into a layer is a secret leaked to anyone who can pull the image, and you cannot un-bake it without rebuilding and re-publishing. Non-secret config (timeouts, feature flags, log level) can sit in plain env or a ConfigMap; secrets get the encrypted, access-controlled path.

Second, NODE_ENV must be exactly "production" in prod, because a surprising amount of the ecosystem branches on it. Express disables verbose error pages and stack traces, caches compiled view templates, and skips work it does in development; many libraries flip dev-only checks off. The footgun is that the check is a plain string compare — NODE_ENV=Production, prod, or a stray space silently lands you in the non-production branch, so you ship with dev-mode error pages leaking stacks and view caching off, paying a real latency tax with no error to point at. This is why validating NODE_ENV against an enum (above) is not pedantry: a typo here degrades prod silently.

Why this works

Here’s the layer the OOMKill hides in. V8 caps the old-space heap — long-lived objects — and historically that cap was auto-sized from the physical machine’s memory (roughly 1.5–2 GB on older 64-bit defaults, larger when V8 sees a big host). The trap: older Node builds were not cgroup-aware. Inside a container limited to 512 MB, Node would read the host’s 32 GB via os.totalmem()-style probes, set a multi-gigabyte heap ceiling, and happily let the heap grow toward it. Long before V8 hits its own limit and throws a clean JavaScript heap out of memory, the process crosses the cgroup’s 512 MB, and the kernel’s OOM killer sends SIGKILL. The exit code is 137 (128 + 9, signal 9). Crucially there is no V8 error, no JS stack — V8 was never asked, the kernel just took the process. That asymmetry is the whole debugging trap: you look for a heap error that can’t exist, because the heap limit was never the one that fired.

Size the heap to the container, via NODE_OPTIONS

The fix is to tell V8 the truth about its budget: set --max-old-space-size to roughly 75–80% of the container memory limit, leaving headroom for everything that isn’t old-space heap — Buffers (off-heap), the C++ stacks, native addons, code, and V8’s own young generation. For a 512 MB limit, that’s --max-old-space-size=384: V8 now starts GCing hard and, if it truly can’t fit, throws a clean JavaScript heap out of memory with a stack you can act on — a loud, attributable failure instead of a silent kernel kill.

# Pass V8 flags via NODE_OPTIONS so they also reach child processes (workers, spawns).
# 512 MB cgroup limit → ~75% for old-space heap, headroom for buffers/stacks/native.
NODE_OPTIONS="--max-old-space-size=384 --max-semi-space-size=64"
# In the image/manifest, not in code — same artifact, env supplies the budget.
ENV NODE_OPTIONS="--max-old-space-size=384"

Put flags in NODE_OPTIONS, not the CMD line: that environment variable is inherited by every node you spawn — worker threads, child_process.fork, a clustered master’s workers — so the limit is consistent across the whole process tree, whereas a flag on the entrypoint applies only to PID 1’s process. The second flag, --max-semi-space-size, sizes the young generation (new, short-lived objects), which V8 scavenges with a fast copying collector; the default is small (~16 MB). Raising it (e.g. to 64 MB) means fewer, larger young-gen GCs — higher throughput for allocation-heavy services at the cost of more resident memory, so it comes out of the same budget you just sized. The numbers to carry: 512 MB limit → 384 MB heap; default semi-space ≈ 16 MB; OOMKill exit code = 137 with no JS stack; a clean V8 OOM exits 134 (abort) with a heap out of memory message.

Pick the best fit

A containerized Node service has a 512 MB memory limit and keeps getting OOMKilled at exit 137 with no heap error. How should you size the heap?

Quiz

A container has a 512 MB limit. Node was started with no --max-old-space-size on an older, non-cgroup-aware build. The pod dies with exit code 137 and no heap error in the logs. Why?

Recall before you leave
  1. 01
    Why does a container with a 512 MB limit get OOMKilled at exit 137 with no V8 heap error, and what's the fix?
  2. 02
    Why validate the whole config at boot and crash, instead of reading process.env lazily where each value is needed?
Recap

Configuration is everything that varies between deploys, and it belongs in the environment — not in code, not baked into the image — so a single artifact promotes dev → staging → prod with only env vars changing. Read that environment exactly once at boot into a typed, frozen object, coercing the always-string process.env values to real numbers and booleans in one place rather than scattering process.env.X reads everywhere. Validate the whole shape with a schema and process.exit(1) on any missing or malformed var before the server binds, converting a class of 3am runtime 500s into a single loud boot failure the deploy pipeline catches — at the small cost of declaring every variable. Keep secrets out of the image (inject them at runtime from a secret store, never commit or bake them) and pin NODE_ENV to exactly production, since a typo silently drops you into dev branches that leak stacks and skip caching. Finally, size the runtime to the container: an older, non-cgroup-aware Node auto-sizes its V8 old-space heap from the host’s physical memory, so inside a 512 MB limit it lets the heap grow past the cap and the kernel OOM-kills it at exit 137 with no JS stack — set --max-old-space-size to ~75% of the limit (384) via NODE_OPTIONS so the flag also reaches child processes, optionally tune --max-semi-space-size for young-gen throughput, and turn a silent kernel kill into a clean, attributable failure. Now when you see exit code 137 with no JS stack, or a service that 500s at 3am but looked healthy at deploy, you’ll know which layer to fix first.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.