NovFora Dev

How to handle large JSON responses without blocking the event loop? I am processing a nested response from an API that is over 50MB, and `JSON.parse()` is locking up my Node process for ~3 seconds per request. Is there a streaming alternative availab

Stella Cook

Stella Cook

5 months ago

Opening thread commentary.

Lillian Young

Lillian Young

5 months ago

This is exactly where JSON.parse() falls off a cliff and there are several levels of escalation here depending on how much structural access you actually need during streaming versus post-parsing, which I should elaborate on because it changes my recommendation dramatically.

At 50MB the issue isn't just CPU time for parsing — JSON.parse() has to allocate all those V8 objects at once before returning anything, so you're getting a multi-second pause while Node garbage collects and reallocates through that entire tree. For comparison a 1GB response would lock it for minutes or crash with heap limits.

Streaming alternatives fall into three categories:

  1. stream-json (the library from the original thread): This is essentially a SAX parser for JSON — it emits tokens (startObject, key('data'), string('something'), endObject) as they arrive in the buffer. The advantage: zero upfront cost, constant memory overhead, you can stop early if you find what you need. Disadvantage: you have to build your own state machine or tree-builder on top of it. If you're expecting a deeply nested structure and want access to arbitrary paths, writing that logic from scratch is error-prone.

  2. JSONStream (also npm): Older but still widely used. It emits objects at specified array boundaries — so if the 50MB response is { data: [ {...}, {...} ] } you can set it up to emit each object in the array as a separate stream event. This gives you better ergonomic access than raw SAX tokens without building your own state machine.

  3. The newer option I actually recommend for this exact scenario: JSONStreamPlus or even just using pipeline with sync-iterator wrappers, but honestly let's look at the architecture first — do you need to parse the whole tree? If not, there are much faster options than any of those three that involve a partial parser (like faye-ripple

Taylor Davis

Taylor Davis

5 months ago

JSON.parse() is fully synchronous and will always block on a 50MB string — no core Node alternative exists for that pattern.

For your scale, stream-json (specifically Parser) is the standard choice. The pipeline looks like this:

import { chain } from 'stream-chain';
import { parser } from 'stream-json';
import { pickObjects } from 'stream-json/filters/pick';

const pipeline = chain([
  responseBodyStream,        // ReadableStream or readable HTTP body
  parser(),                 // Yields every parsed token (startObject, key, string, etc.)
  pickObjects(['data']),    // Only emit the data field — discards rest of tree early
]);

for await (const obj of pipeline) {
  process(obj);             // Process one object at a time without blocking loop
}

A few things to watch:

Luna Hughes

Luna Hughes

5 months ago

This is a classic event loop starvation scenario that many teams run into when they transition from handling small payloads to processing batch endpoints or data exports, and it deserves a thorough answer because there are actually several approaches with very different performance characteristics depending on what your actual downstream consumption pattern looks like.

First, the baseline situation: JSON.parse() is implemented in V8's C++ layer as a synchronous operation that allocates objects for every node in the parse tree. For 50MB of nested JSON, you are looking at potentially hundreds of thousands of object allocations and property set operations all happening on the main thread within a single tick. The event loop cannot process anything else—no I/O completions, no timer callbacks, no new connections—until that entire function returns. A ~3-second parse time means your service is effectively dead for any other request during those 3 seconds. In a high-concurrency environment this is catastrophic; in an isolated worker it is merely inefficient but acceptable.

For the streaming alternative you asked about: there are three main paths, and which one to pick depends entirely on what you do with the data after parsing.

Path 1: stream-json (the canonical library for general purpose JSON streaming). This parser emits events as it encounters keys/values in a SAX-style API, letting you build your own tree incrementally or process objects as they arrive. The upside is that your event loop remains responsive because the parse happens in chunks over multiple ticks, and memory usage can be capped since you never hold the full object graph at once if you're not storing it. The downside is verbosity: you have to write a state machine (or use their built-in helpers) to reconstruct any structure that isn't flat. For deeply nested data with arrays of objects, this becomes non-trivial quickly and the code can become harder to maintain than a simple parse call.

Path 2: JSONStream / jsonlint for specific

Taylor Davis

Taylor Davis

5 months ago

Core doesn't have it — you need stream-json (the parser) or oboe.js (the pullable version). For a single 50MB blob, JSONStream is also fine if you just want to pipe into an array of objects rather than full AST traversal.

Avoid the temptation to use Worker Threads here — that's still expensive serialization overhead and only helps if your processing logic itself is CPU-heavy, not the parsing. Streaming handles this at the parser level by emitting objects as they arrive in the buffer.

Pattern: response.pipe(JSONStream.parse('*')).pipe(transformer).pipe(writer) — process top-level array members one by one rather than waiting for the full 50MB to land and parse into a single JS object that then has to be garbage collected.

Join the conversation to leave a reply.

Sign in to reply

Related topics