2026-10-29 ยท engineering note
A guard that refuses to answer with a non-finite number is easy to argue for and easy to
place badly. Ours sits on the one line every handler's response passes through โ the
res.json interception โ because that placement covers every endpoint at once
instead of asking 1,700 handlers to remember. The price of that convenience is that the
guard now runs on every response, and the server is single-threaded. Whatever it
costs, every concurrent request waits behind it.
So before defending the design, measure the tax.
The rule we try to hold: a guard that runs on the request path needs a number attached to it, in the same units as the work around it. Otherwise "it's only a walk" is a belief, not an argument.
The temptation is to rewrite the function in a benchmark and time the rewrite. That measures a paraphrase. The function here is small and has no dependencies, so extracting it verbatim out of the deployed file and compiling it is both cheaper and more honest. The extraction is brace-counted, not line-ranged, so a comment added above it does not silently shift what gets timed.
const fn = new Function(extract("function findNonFinite(", source) + "; return findNonFinite;")();
Two real bodies, pulled live, plus synthetic shapes to find the slope:
| body | bytes | nodes | p50 | p99 |
|---|---|---|---|---|
| /api/services | 736,286 | 28,421 | 0.936 ms | 1.382 ms |
| MCP tools/list | 465,549 | 18,317 | 0.883 ms | 1.528 ms |
| synthetic | 224,867 | 24,004 | 0.916 ms | 1.762 ms |
| synthetic | 937,848 | 96,004 | 3.814 ms | 4.890 ms |
Two hundred samples each. The cost is linear in nodes at 35โ58 ns per node, and it is
node count that matters, not bytes: a 7.7 MB body made almost entirely of prose strings
walks in under a millisecond because a string leaf fails the typeof === "number"
test and returns immediately.
For comparison, the JSON.stringify that was already happening on every one
of those responses is more expensive than the guard. On a 2,000-element body with
five numeric fields each, the guard measures 0.505 ms against 1.587 ms for the serialisation
it precedes. That is the correct frame for the number: not "1 ms is a lot", but "1 ms next
to the 3 ms of unavoidable work already on this line".
2,404 nodes 200 calls ON 19.15 ms OFF 0.090 ms
24,004 nodes 200 calls ON 191.86 ms OFF 0.027 ms
96,004 nodes 200 calls ON 770.29 ms OFF 0.027 ms
The OFF branch is the same loop with the guard removed โ what the line cost
before the patch. This is measured inside one Node process, not by calling a live endpoint
two hundred times. The guard is pure CPU over an object graph that has already been built;
network time and handler time are not part of what it costs, and mixing them in would
produce a number too noisy to act on. What the in-process figure buys is the delta, and the
delta is the whole question.
The walk stops at depth 6 โ the same width the neighbouring sanitiser uses. A 10,000-level object returns in 0.083 ms and never recurses past the seventh frame. The cap is why the answer to "can a hostile body overflow the stack?" is no, and it is worth stating that the same cap is a declared blind spot: a non-finite number below level 7 is not seen. In practice that cannot come from parsed JSON, since JSON has no Infinity literal โ the value can only be produced by the handler's own arithmetic, and handler results are shallow. But the blind spot is a property of the guard, and it belongs in the docstring rather than in someone's head.
Our first run came back at 0.000 ms on every body, including the 736 KB one. The guard was fine. The benchmark was wrong, in a way worth spreading around:
const ctx = vm.createContext({});
vm.runInContext(src + "\nthis.f = f;", ctx);
ctx.f(bigObject, "$", 0); // returns null after ONE node
The function uses v.constructor !== Object to skip non-plain objects. Compile
it in a vm context and it sees that context's Object, while every
object you hand it was built by the outer realm's Object. The identity check
fails for all of them, the walk returns immediately, and the guard "costs nothing". A
benchmark that sandboxes the code under test will measure the sandbox boundary instead of
the code. Compiling with new Function keeps one realm and the number becomes
real โ 0.936 ms, not 0.000.
The same family of mistake produced the number we had quoted before measuring properly: "about 1.5 ms for 2,000 elements" was the guard plus the serialisation, reported as if it were the guard alone. It is a 3ร overstatement. Anyone who has published a wrong microbenchmark has made this exact substitution.
We did not add a node-count cap. The guard is linear, the handlers' input is capped at 100 KB upstream, and the realistic bodies here sit two orders of magnitude below the shape that costs 4 ms. A threshold would be code that never fires, and thresholds that never fire rot into fiction โ the sort of thing a later reader trusts and a later refactor breaks.
What we did instead was turn the measurement into a tool with its own controls, so the next person can re-run the same six checks against whatever the deployed bytes have become. The controls are not decoration: one of them asserts that the depth cap is still a blind spot, so if someone changes the width, the tool says so instead of quietly reporting a different guard's numbers.
If you take one thing from this: put a number on the guard before you defend it, and make sure the number comes from the bytes that are running. The second part is where benchmarks usually lie.
minia2a is an x402 pay-per-call marketplace: 1,700+ endpoints, USDC on Base, five free trial calls per signed wallet. The measurement tool described here lives in the platform's own repository.