A webhook receiver I built a few years ago always worked the same way: nginx took the connection, nginx forwarded the body, and the application decided whether the payload was junk. nginx 1.31.5 adds a module that lets nginx make that decision itself, by reading fields out of the JSON body before the request ever reaches a location block. I wanted to know what that was actually worth, not just what the changelog implied.

The module is ngx_http_json_module, built with --with-http_json_module (it's compiled into the official Docker image, but not into Ubuntu's 24.04 nginx apt package, which still ships 1.24.0). It adds one directive that matters, json_set, plus a depth limit. Alongside it, 1.31.5 also shipped predicate locations (location $variable) and client_body_early_read, which together let a location match be decided by the request body instead of only the URI.

I pulled nginx:1.31.8 (the current mainline patch, released 21 October 2026) and built the smallest realistic case: a webhook endpoint that only wants two event types, payment.created and payment.failed, and should reject everything else without bothering the application.

The setup

Old way, everything proxied through:

server { listen 18090; location / { proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://backend; } } 

New way, rejecting at the edge:

client_body_early_read 1; json_set $event_type $request_body event.type; map $event_type $is_invalid_event { "payment.created" 0; "payment.failed" 0; default 1; } server { listen 18091; location $is_invalid_event { return 400 "unknown event type\n"; } location / { proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://backend; } } 

The backend is a small threaded Python server that parses the same JSON and returns 400 for anything that isn't one of the two valid event types, so both paths enforce the identical rule. I ran everything on loopback, no Docker network between containers, --network host so there's no extra hop to argue about.

What it actually saved

I sent 200 requests, alternating valid and invalid payloads, through each config and counted how many reached the backend:

requests sent reached backend
old way (run 1/2/3) 200 200 / 200 / 200
new way (run 1/2/3) 200 100 / 100 / 100

Every single invalid request was turned away at nginx. The backend never saw it. That's the entire point of the feature in one table: it's not about nginx being faster at HTTP, it's about deciding which requests are worth a backend's time before spending any.

The headline number

At concurrency 1, no contention, I measured the time to reject (or round-trip and reject) 1,000 invalid payloads, three times each:

run old way (round trip) new way (rejected at nginx)
1 1,840 req/s 25,780 req/s
2 2,006 req/s 21,862 req/s
3 1,775 req/s 25,153 req/s

Averaged, that's about 1,874 req/s against 24,265 req/s — roughly 13 times faster to say no. That gap is real and it isn't contention-related, since there's only one request in flight at a time either way. It's the cost of a TCP round trip and a Python thread versus a variable lookup.

The opposite reading

The obvious question is what this costs you when there's nothing to reject. I ran 3,000 all-valid requests through both configs, serially, three times:

run old way new way
1 1,729 req/s 1,680 req/s
2 1,578 req/s 1,688 req/s
3 1,572 req/s 1,574 req/s

That's noise, not a trend. I expected json_set plus the extra map lookup to cost something on every request even when nothing gets rejected, and I couldn't find it. At a 5% invalid mix the new way was consistently about 11-13% faster even though 95% of requests still had to go all the way to the backend either way, which argues the parsing cost is genuinely small relative to a real round trip.

Under concurrency, the comparison stops being fair

At 10 concurrent clients sending nothing but junk, old way managed 1,433 req/s; new way did 86,046. At 100 concurrent clients, old way dropped to 49 req/s with 3 failed requests, while new way stayed above 109,000 with none. I'm not reporting that ratio as nginx being thousands of times faster than a backend in general — it's what happens when a single-process, thread-per-connection Python server gets saturated by a flood of requests it has to accept, parse and reject one at a time. Hit directly with no proxy in front, that same backend topped out around 2,500 req/s at concurrency 50. The real finding isn't the multiplier, it's that under load the backend's own capacity becomes the thing junk traffic eats into, and removing it from that path removes the exposure entirely rather than just making it faster.

What the docs claim, checked

The module docs say a JSON document "is parsed only once per request, on the first access to any variable extracted from it, no matter how many variables are extracted." I tested that by extracting one field against extracting five fields from the same request body, 5,000 requests at concurrency 1, three runs each:

run 1 field 5 fields
1 26,202 req/s 26,089 req/s
2 25,874 req/s 24,203 req/s
3 26,080 req/s 26,180 req/s

Within 2% either way. The claim holds: whatever json_set does internally, it isn't re-walking the document for every variable you pull out of it.

What it refuses

json_max_depth defaults to 32. I sent a document nested 40 levels deep and asked for a value at the bottom:

$ curl -s -X POST http://127.0.0.1:18081/ --data-binary @deep40.json deep=[] 

No error, no log line, nothing in error_log. The variable is simply not found, exactly as if the field didn't exist or the JSON were malformed. If you're validating depth-sensitive payloads (deeply nested configuration blobs, arbitrary user-supplied trees), this will silently pass them through as "field missing" rather than flag them as "too deep," and your map block needs to treat those two cases the same way whether you like it or not.

Separately, client_body_early_read is not the boolean it looks like. Its syntax is client_body_early_read _string_ ...;, matched against one or more values, enabled if any is non-empty and isn't "0". Writing client_body_early_read on; works, but only because the literal string "on" happens to be non-empty and not "0" — nginx isn't parsing it as a keyword. client_body_early_read off; would also enable it, which is exactly backward from every other *_module on/off directive in nginx and worth knowing before you copy a pattern from somewhere else in your config.

What I got wrong on the way

My first throughput run reported the new way at roughly 290 times faster than the old way at 50 concurrent clients, and I almost wrote that number down. It turned out to be a harness bug: proxy_pass to the backend defaults to HTTP/1.0 with Connection: close, so without an explicit upstream { keepalive } block, nginx was opening a brand-new TCP connection per request to a backend on loopback, and at concurrency 50 that was exhausting ephemeral ports faster than they could leave TIME_WAIT. The old way wasn't slow because it round-trips to a backend; it was slow because my config made every one of those round trips pay for a fresh handshake. Adding keepalive 64 to the upstream block and proxy_http_version 1.1 dropped the measured gap from 290x to a defensible 13x at low concurrency. The lesson wasn't about JSON parsing at all — it was that an unrealistic proxy config can make the "old way" look artificially bad, which would have flattered the feature I was trying to measure fairly.

Run it yourself

docker pull nginx:1.31.8 cat > new_way.conf <<'EOF' worker_processes 1; events { worker_connections 2048; } http { access_log off; client_body_early_read 1; upstream backend { server 127.0.0.1:9001; keepalive 64; } json_set $event_type $request_body event.type; map $event_type $is_invalid_event { "payment.created" 0; "payment.failed" 0; default 1; } server { listen 18091; location $is_invalid_event { return 400 "unknown event type\n"; } location / { proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass http://backend; } } } EOF docker run -d --name newway --network host \ -v "$(pwd)/new_way.conf:/etc/nginx/nginx.conf:ro" nginx:1.31.8 curl -s -X POST http://127.0.0.1:18091/ -d '{"event":{"type":"payment.created"}}' curl -s -i -X POST http://127.0.0.1:18091/ -d '{"event":{"type":"spam.click"}}' 

Without a listener on 9001 the valid-case curl just gets a 502, so point something at it first — any script that reads the body and answers 200 will do.

Before you add this, run nginx -V 2>&1 | grep json against whatever you've actually got running. The Ubuntu package doesn't have it. The official Docker image does. If yours does, pull yesterday's access log and count the 400s and 422s your app already wrote. That count is what tells you whether fifteen minutes of config is worth it, not the 13x I got against a toy backend.