Rebounder Tech Blog

Written by the people who actually run these systems in production.

A Non-Numeric env Becomes NaN and Slips Past ??

Published About 5 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

raw ? Number.parseInt(raw) : undefined only checks truthiness, and the NaN a non-numeric env produces passes straight through a later ?? default, which rejects nullish values only.

The short version

raw ? Number.parseInt(raw) : undefined only checks truthiness, and the NaN a non-numeric env produces passes straight through a later ?? default, which rejects nullish values only. Two jobs used this pattern. One failed silently, producing not a single embedding; in the other, setTimeout(abort, NaN) fired at effectively 0 ms and every fetch was aborted the instant it started.

What it looks like

Two independent bugs surfaced at the same time in a batch job environment.

The first was the embedding job. Batch size is controlled by an env var, EMBED_BATCH_SIZE, and when that value is non-numeric the chunking loop never runs a single iteration, so zero embeddings are produced. No error. The job looks like it finished normally, having processed nothing.

The second was a job fetching railway delay information. Fetch timeout is controlled by RAILWAY_FETCH_TIMEOUT_MS, and when that value is non-numeric the fetch is aborted immediately after starting. The data stays permanently stale and updates stop.

Neither is simply a case of the env value itself being wrong. The problem was how the code interpreted a non-numeric env var.

Why

The embedding job’s entry point was written like this.

const batchSizeRaw = env.EMBED_BATCH_SIZE;
const config: RunEmbeddingBatchConfig = {
  // ...
  batchSize: batchSizeRaw ? Number.parseInt(batchSizeRaw, 10) : undefined,
};

The railway job’s entry point had the same shape.

const timeoutRaw = env.RAILWAY_FETCH_TIMEOUT_MS;
const config: RunRailwayFetchConfig = {
  // ...
  timeoutMs: timeoutRaw ? Number.parseInt(timeoutRaw, 10) : undefined,
};

The ternary raw ? Number.parseInt(raw) : undefined looks like a safe way to say “fall back to the default when it is unset”. But what that ? inspects is raw’s truthiness, not whether the parse succeeded. A non-empty non-numeric string such as EMBED_BATCH_SIZE="abc" is truthy, so Number.parseInt("abc", 10) runs and its NaN is assigned to batchSize. The guard catches only “unset”; “set but invalid” walks past it.

That NaN was not stopped by the next layer either.

On the embedding side, embed-content.ts handles the batchSize it receives like this.

const batchSize = Math.max(1, Math.trunc(options.batchSize ?? 32));

When options.batchSize is NaN, NaN ?? 32 returns NaN. ?? yields the right-hand side only when the left is null or undefined, and NaN is neither, so it passes through. Math.trunc(NaN) and Math.max(1, NaN) are both NaN, and batchSize reaches the chunking loop as NaN. The loop condition is never satisfied, so it runs zero iterations and finishes with zero embeddings.

The railway job handles it in fetchMeitetsuStatus in run.ts.

const timeoutMs = config.timeoutMs ?? 10_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);

For the same reason, config.timeoutMs reaches timeoutMs still as NaN. setTimeout(callback, NaN) runs essentially immediately per spec, so the AbortController fires abort() right after the call and every fetch is cancelled the moment it begins.

Both places share one structural defect. Make a single condition serve as both “reject the unset case” and “validate the value”, and a third state — non-numeric — passes through without hitting either guard.

Fixing it

We added a helper, optionalIntEnv, that validates the Number.parseInt result with Number.isFinite before returning it.

function optionalIntEnv(name: string): number | undefined {
  const raw = env[name];
  if (!raw) return undefined;
  const n = Number.parseInt(raw, 10);
  return Number.isFinite(n) && n > 0 ? n : undefined;
}

Call sites replace batchSizeRaw ? Number.parseInt(batchSizeRaw, 10) : undefined with optionalIntEnv("EMBED_BATCH_SIZE"), and a non-numeric value reliably becomes undefined, falling back to the default (32 / 10 seconds).

But fixing only the entry point does not close the paths where NaN reaches the embedding logic or run.ts directly — from tests or other callers. So the receiving side got Number.isFinite guards too.

// embed-content.ts
const rawBatch = Math.trunc(options.batchSize ?? 32);
const batchSize = Number.isFinite(rawBatch) ? Math.max(1, rawBatch) : 32;
// run.ts
const timeoutMs = Number.isFinite(config.timeoutMs) ? (config.timeoutMs as number) : 10_000;

Stopping it at the entry point is the real fix, but replacing ?? with Number.isFinite on the receiving side as well means that if either layer is later rewritten back into the same ternary, the other layer still stops the NaN.

Preventing a repeat

The fix commit added regression tests pinning that everything is still embedded when batchSize=NaN is passed, and that timeoutMs=NaN does not abort immediately. Other places may also treat a value read from env directly as a number, so the fact itself — ?? does not reject NaN — is left as a code comment, so whoever writes the next env-parsing routine does not reproduce the same ternary.

On defaults for environment variables, When the default bucket stays staging, destroy deletes production images covers a design mistake in the condition for falling back to a default. There the cause was the content of the default (which environment it pointed at); here the gap was in the test that decides whether to fall back — neither the truthy check nor nullish coalescing rejects NaN. Code that treats env vars as numbers is worth checking not just for the value but for which guard stops a non-numeric one.

Frequently asked questions

Q1What is wrong with raw ? Number.parseInt(raw) : undefined?

If raw is empty or unset you get undefined. But a non-empty non-numeric string like "abc" passes the truthy check, Number.parseInt(raw) runs, and its NaN is returned as-is. The caller receives NaN as if it were a valid number.

Q2Why doesn't the ?? default stop NaN?

?? returns the right-hand side only when the left is null or undefined. NaN is neither, so nullish coalescing does not apply to it and an expression like config.timeoutMs ?? 10_000 passes NaN straight through.

Q3How was it fixed?

We added a helper, optionalIntEnv, that validates the Number.parseInt result with Number.isFinite before returning it. The call sites in embed-content.ts and run.ts also guard with Number.isFinite, so if a value slips past the default there is still a last line of defence.

Environment verified

  • TypeScript ^6.0.3 (apps/jobs)
  • As of the fix commit on 2026-06-16

What this article is based on

  • TypeScript file lines 84-93commit f0a7543
  • TypeScript file lines 30-65commit f0a7543
  • TypeScript file lines 31-63commit f0a7543
  • TypeScript file lines 78-86commit f0a7543

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.