Parsing high-throughput text streams in Node without falling behind

8 min readYaseen Khatib · MERN + AI Architect

Chat protocols, NDJSON logs, market feeds and most line-delimited wire formats share a shape: a stream of bytes with a delimiter between messages. At a few hundred messages a second, the naive parser is fine. At several thousand it becomes the reason your process cannot keep up, and the fix is not a faster machine.

The naive version, and its three costs

socket.on("data", (chunk) => {
  for (const line of chunk.toString("utf8").split("\r\n")) {
    handle(JSON.parse(line));     // two bugs and a performance problem
  }
});

Per chunk this allocates a string for the whole chunk, an array from split, and a string per line. At 5,000 messages a second that is a lot of short-lived garbage, and the collector will find time for it whether you have any to spare or not.

It is also wrong twice, and both bugs are invisible under light load.

Bug one: messages straddle chunks

TCP does not deliver your messages, it delivers bytes. A chunk can end halfway through a line, and the next chunk begins with the rest of it. The naive parser treats the fragment as a complete message, JSON.parse throws, and you drop data — but only under exactly the load that makes chunks large enough to split mid-message.

You need a remainder buffer:

const DELIM = Buffer.from("\r\n");
let rest = Buffer.alloc(0);

socket.on("data", (chunk) => {
  let buffer = rest.length ? Buffer.concat([rest, chunk]) : chunk;
  let start = 0;

  for (;;) {
    const index = buffer.indexOf(DELIM, start);
    if (index === -1) break;
    handleLine(buffer.subarray(start, index));   // a view, not a copy
    start = index + DELIM.length;
  }

  rest = start < buffer.length ? buffer.subarray(start) : Buffer.alloc(0);
});

Buffer.indexOf is implemented natively and scans quickly. subarray returns a view over the same memory rather than copying, so slicing out fifty messages allocates fifty small views rather than fifty new buffers.

One caveat that matters: because rest is a view into the previous chunk, it keeps that whole chunk alive in memory. For a small trailing fragment of a large chunk, copy it instead — Buffer.from(buffer.subarray(start)) — so the big allocation can be collected.

Bug two: characters straddle chunks too

A multi-byte UTF-8 character can also be split across chunks. chunk.toString() on a boundary produces a replacement character, and the data is corrupted in a way that survives all the way to your database.

StringDecoder exists exactly for this:

import { StringDecoder } from "node:string_decoder";
const decoder = new StringDecoder("utf8");

const text = decoder.write(chunk);   // holds back an incomplete character

It buffers the incomplete trailing bytes and prepends them to the next call.

Note it solves only the character problem, not the message problem. You need both: the delimiter scan for messages, and either StringDecoder or byte-boundary-aware slicing for characters. Most parsers that get one right get the other wrong.

Stay on buffers as long as you can

The largest remaining win is not decoding what you never read. Many protocols are mostly routing metadata with a payload you only sometimes need:

function handleLine(line) {
  // Route on bytes: no string allocated for messages we ignore.
  if (line.length > 4 && line[0] === 0x50 && line[1] === 0x49) {  // "PI"
    return respondToPing();
  }
  const message = JSON.parse(line.toString("utf8"));  // only when needed
  dispatch(message);
}

Comparing a few bytes is dramatically cheaper than decoding a string and matching on it, and on a feed where most messages are keep-alives it removes almost all of the parsing cost.

When readline is the right answer

For files and moderate rates, do not write any of this:

import { createInterface } from "node:readline";

const lines = createInterface({ input: createReadStream("events.ndjson"), crlfDelay: Infinity });
for await (const line of lines) {
  handle(JSON.parse(line));
}

It handles both boundary problems, gives you backpressure for free through for await, and is fast enough for the overwhelming majority of workloads. Reach for a hand-written parser when you have measured that it is the bottleneck — not before, because the manual version is where the two bugs above live.

Measure the right thing

Throughput is the wrong number. What matters is whether you are keeping up, which shows as queue depth or lag rather than messages per second.

Track the gap between arrival and processing, and watch the socket's buffered amount. If either grows, you are falling behind, and no amount of parser micro-optimisation fixes a consumer that is simply slower than its producer — that is a backpressure problem, and a different post.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.