Solving Event Loop Blocks in JSONata, for Any Input
This is a story from the ingestion engine I built at Sprinto. The surface problem was a frozen worker. The real problem was harder: how do you keep an expression language fully expressive when any expression, over a big enough input, can block the event loop?
The problem
Our integration runs execute as jobs that must heartbeat every 30 seconds, or the orchestrator assumes the worker is dead and kills the run. Large runs were dying at almost exactly 15 minutes. Not slow. Frozen. A CPU profile pointed at JSONata, the query language our plans use for data transformation: a nested object constructor inside a $map re-evaluates against the whole input per item. What reads as a linear transform is roughly O(n^2.5). At 30 thousand records that is 15 minutes of synchronous work, exactly the heartbeat timeout.
But the specific bug is almost beside the point. JSONata is powerful precisely because authors can chain expressions and nest loops freely. Any of those, over enough records, holds the event loop hostage. The naive advice is “process the array in chunks.” That works when the loop is yours. Here the loop belongs to the library, driven by expressions our customers write. Chunk the input and you break every transformation that needs the whole sequence: group-bys, joins, aggregations, dedup. Or you ban those constructs and gut the language.
So the real challenge: keep every JSONata capability, for any input size, and keep the process alive.
Chunks, discarded fast
My first attempt was exactly the naive one: split the input, run the expression per chunk, merge. It died quickly. One nested syntax, one aggregation, one expression that looks across items, and chunked results are simply wrong. Correctness would have depended on what the author wrote, which is another way of saying the feature is broken.
The idea came from an older fight
I had solved a version of this before, inside graphql-js itself, the reference implementation everyone uses. GraphQL validates every node of a response against the type contract, and on large or deeply nested responses that validation walk blocks the same way. The fix there was not to validate less. It was to go inside the resolve loop, keep a clock, and when the loop held the event loop past a threshold, yield and continue on the next tick. That took our event loop blocks from 90 to 150 milliseconds down to under 30. (That one deserves its own article.)
The pattern generalizes: don’t chunk the data, teach the interpreter’s own loop to yield. The library is already iterating item by item. If you can hook that loop, you get cooperative scheduling for free, and the author’s expression never knows it happened.
The fix, in layers
Layer 1: fix the pathological pattern. The O(n^2.5) constructor shape has three equivalent linear rewrites: a small builder function that constructs objects in plain JavaScript, an anchored constructor that keeps paths relative to the current item, or hoisting the inner object into a variable. We fixed the live expressions and documented the cheapest rewrite first.
Layer 2: make the loops yield. Every JSONata expression in the engine now compiles through one entry point. It swaps the built-in $map and $filter for versions that replicate JSONata’s exact iteration semantics, plus one addition: a clock. When the loop has held the event loop for 50 milliseconds, it yields and resumes on the next turn. Full expressiveness, any input size, and the heartbeat fires.
The subtle part is how you yield. This snippet is the whole insight:
// starves timers: microtasks drain before the event loop turns
await Promise.resolve()
// actually lets the heartbeat fire: schedules a macrotask
const { setImmediate } = require('timers/promises')
await setImmediate()
Awaiting a resolved promise or process.nextTick feels like yielding, but microtasks drain before timers get a turn. The heartbeat still starves. Only a macrotask like setImmediate lets the event loop actually rotate. One line decides whether your keepalive keeps anything alive.
The same 50 millisecond budget yielder is exposed as a helper for hand written loops over large arrays, so the pattern is not JSONata specific.
Layer 3: make the worst pattern impossible to reship. A validation rule walks the JSONata AST (not a regex) and rejects the super quadratic constructor shape at save time. The same detector runs offline against existing plans. The first sweep found seven occurrences already in the wild, including one in a production template. Each was a freeze waiting for a big enough customer.
The lessons
- Chunk the loop, not the data. Splitting input breaks semantics. Yielding inside the interpreter’s own iteration preserves them.
- Good patterns transfer. The graphql-js fix and the JSONata fix are the same idea wearing different libraries.
- Microtask vs macrotask is not trivia. It is the difference between a heartbeat that beats and one that silently starves while your process looks busy.
- “Slower but alive” beats “fast but dead”. A 50ms yield budget costs a little throughput and buys survival. For long jobs guarded by heartbeats, that trade is always right.
- A postmortem that only fixes the instance is half done. The AST rule at save time means this class of bug now fails at authoring, not at 2am under a customer’s biggest dataset.