DEV Community

Taylor Wang
Taylor Wang

Posted on

My Streaming Client Froze for Six Seconds. The Server Was Innocent.

I pointed a small streaming client at MonkeyCode's free model access through their free server option. My goal was to see how a chat interface behaved under real network conditions. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Short prompts streamed out token by token, smooth as a marketing video, but long prompts sat silent for six seconds. After that silence, the entire answer landed in one frame, and the console immediately threw a SyntaxError.

Same endpoint, same client, same code path — so why did response length change the transport behavior? The honest answer, which I resisted for an hour, is that the model and the server were never the problem. My parser was the only broken component in the chain, and finding that took far longer than it should have.

The Tempting Suspect Was the Free Server

When a free server misbehaves, the easiest theory in the world is that the provider is cutting corners somewhere. I spent a comfortable hour convincing myself that the server was batching events, delaying flushes, or throttling long responses. None of those theories survived contact with the raw bytes, and that contact took about thirty seconds to arrange.

The discipline that keeps me sane is simple: suspect my own client before I suspect the model. I violated that rule for an hour, and the evidence that cleared the server took thirty seconds to collect. The raw bytes tell the truth; my logs, on the other hand, were telling me whatever I wanted to hear.

Capture the Wire Before Forming Theories

Before touching a single line of parser code, I looked at the raw stream with curl. The -N flag disables buffering so events print the moment they arrive, and piping into xxd -g 1 shows every byte in order. Here is the command shape, with the endpoint and model redacted:

curl -N "$STREAM_URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"model":"$MODEL","stream":true,"messages":[{"role":"user","content":"Write a long code sample with blank lines"}]}' \
  | xxd -g 1 | head -40
Enter fullscreen mode Exit fullscreen mode

The hex dump showed events separated by 0d 0a 0d 0a, which is the CRLF CRLF pair and perfectly valid Server-Sent Events framing. The server was speaking the SSE dialect fluently, and my parser simply did not understand it. That single observation moved the entire investigation from the provider's status page to my own code.

First Bug: I Parsed Chunks as If They Were Lines

My original parser looked something like this:

async function readStream(response) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) return;

    const text = decoder.decode(value, { stream: true });
    text.split("\n").forEach((line) => {
      if (!line.startsWith("data:")) return;
      const payload = line.slice(5).trim();
      if (payload === "[DONE]") return;
      render(JSON.parse(payload));
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The real flaw was my unspoken assumption that TCP segments and TLS records respect newline boundaries. TCP segments, TLS records, and HTTP chunks have no idea what a newline is, and a JSON payload can split in the middle of a string. Short responses fit inside a single chunk, so they passed every time. Long responses spanned several chunks, and the split landed somewhere inside a data: line.

That explained the SyntaxError, but it did not explain the six-second silence and the sudden dump. So I added a rolling buffer, kept my separator as the literal "\n\n", and watched the buffer grow until the connection closed. The whole response arrived at once, and the same SyntaxError followed right behind it.

Second Bug: The Wrong Blank Line

Here is the subtle part that cost me the evening: the server framed events with "\r\n\r\n", and "\r\n\r\n" does not contain "\n\n". A carriage return sits between the two line feeds, so my split never matched, and every event accumulated until the stream ended.

This is the rare bug that looks like a server outage, smells like a network fault, and turns out to be a string-matching problem. The fix is not a bigger buffer; it is a parser that normalizes the dialect before splitting.

The Fix: Normalize First, Split Second

Instead of searching for both line-ending styles at once, I normalize everything to LF and then split on "\n\n". The parser buffers incomplete blocks, accepts CRLF, LF, and even mixed endings, and has no dependencies:

function createEventParser(onEvent) {
  const decoder = new TextDecoder();
  let pending = "";

  return async function consume(response) {
    const reader = response.body.getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      pending += decoder.decode(value, { stream: true });
      pending = pending.replace(/\r\n/g, "\n").replace(/\r/g, "\n");

      const blocks = pending.split("\n\n");
      pending = blocks.pop() ?? "";

      for (const block of blocks) {
        const payload = extractData(block);
        if (payload) onEvent(payload);
      }
    }

    if (pending.trim()) {
      const payload = extractData(pending);
      if (payload) onEvent(payload);
    }
  };
}

function extractData(block) {
  const dataLines = block
    .split("\n")
    .filter((line) => line.startsWith("data:"))
    .map((line) => line.slice(5).trimStart());

  return dataLines.length ? dataLines.join("\n") : null;
}
Enter fullscreen mode Exit fullscreen mode

Wiring it to a fetch response looks like this:

const parser = createEventParser((payload) => {
  if (payload === "[DONE]") return;
  const { choices } = JSON.parse(payload);
  const delta = choices?.[0]?.delta?.content;
  if (delta) chatBox.textContent += delta;
});

await parser.consume(response);
Enter fullscreen mode Exit fullscreen mode

Three details make this version worth keeping. First, TextDecoder with { stream: true } reassembles multi-byte UTF-8 characters that arrive split across chunks. Second, normalization means "\n\n" is always the boundary, regardless of what the server sent. Third, the buffer never discards a partial block, and the final flush catches an event that arrives without a trailing blank line.

The Debugging Sequence I Keep Now

This failure left me with a short sequence I run before blaming any provider:

  1. Reproduce with the smallest failing prompt. A long code sample with blank lines triggered the bug reliably.
  2. Capture raw bytes before forming theories. curl -N piped into xxd shows the wire truth in seconds.
  3. Isolate the layers: transport, framing, JSON, rendering. I blamed the model first; the hex dump cleared it.
  4. Fix your client before filing a provider issue. The server was spec-compliant the whole time.

Here is what each check actually told me:

Check Tool What it revealed
Raw wire framing `curl -N \ xxd -g 1`
Chunk boundaries Logged chunk lengths A data: line split mid-payload
Separator matching buffer.indexOf("\n\n") Always -1 against CRLF framing
Parsed events JSON.parse on complete blocks Valid JSON once the block was whole

When This Parser Is the Wrong Tool

Keep in mind that this parser is a debugging instrument, not a production dependency. It ignores event: and id: fields, has no reconnection logic, and applies zero backpressure, so a slow consumer can buffer unbounded data. If you are already on an SDK such as the OpenAI Node SDK or the Vercel AI SDK, use their built-in streaming helpers instead.

Reach for a hand-rolled parser when you are inspecting a raw endpoint, or when the SDK hides the wire format you need to see. It is also a great way to internalize the spec. For anything resembling production, eventsource-parser and @microsoft/fetch-event-source have already fought these battles. The tiny parser is a scalpel for debugging, not a hammer for shipping.

The Takeaway

How many of the bugs I blamed on the model were actually my own parsing mistakes? In this session, every single one. The model produced clean output, the server framed it correctly, and my client was the only thing misbehaving.

MonkeyCode's free model access and free server option worked as advertised; the failure was entirely on my side of the socket. The next time a stream goes silent and then dumps everything at once, I will check my separator before I check the provider's status page.

Have you ever watched a stream go silent, blamed the provider, and then found the culprit in your own parser? I would love to read that story in the comments.

Top comments (0)