DEV Community

babycat
babycat

Posted on

The Network Tab Said 200, but the Streaming Chat Never Finished: A Response-Frame Debugging Retrospective

Some streaming failures announce themselves with a loud network error, but this one stayed quiet. The browser showed a 200, the server logs completed, and every unit test passed, yet the chat kept announcing that it was still thinking long after the last visible token arrived. The final token looked half-finished, and the assistive-tech status region never reached a completed state. That combination pointed away from the prompt and toward the way the stream was being terminated.

To reproduce the failure without spending a paid route, I pointed a small harness at MonkeyCode's free model access and ran the proxy on the free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free path mattered less for cost and more for control: I could inspect raw response frames without an SDK hiding the transport details.

Start with the raw frames, not the framework

Language model SDKs often smooth over streaming quirks, which is useful in production but dangerous when you are debugging a protocol-level failure. I removed the SDK and sent the same prompt with curl, then wrote the response body to a file so I could inspect the exact frame boundaries.

curl -N 'https://your-endpoint.example/v1/stream?prompt=Explain%20focus%20management%20in%20one%20paragraph.' -o stream.log
tail -c 300 stream.log | xxd
Enter fullscreen mode Exit fullscreen mode

I expected the file to end with data: [DONE] followed by two newlines. Instead, the tail ended with data: "focus mov and then nothing: no closing quote, no terminal event, and no newline. The transport had closed exactly when the upstream stream was interrupted.

Separate the HTTP status from the stream contract

A 200 status only proves that the server sent headers and started a response. It does not prove that a chunked stream will end cleanly. A 200 is like a restaurant confirming your order before the kitchen loses power; the request was accepted, but the meal never arrived.

In this failure pattern, the endpoint or an intermediary proxy began sending chunked data, then the connection dropped before a terminal frame appeared. The browser exposed this as a normal close of a ReadableStream, so the client treated the response as finished. My UI then waited forever for a [DONE] frame that could never arrive.

What the browser showed What the raw body showed Likely cause
200 and a stuck spinner last chunk ends mid-token, no terminal frame upstream closed before sending [DONE]
200 and apparently complete text terminal frame present but data split oddly client parsed before a frame boundary arrived
failed request connection reset network or proxy refused the stream

Make the stream close the source of truth

The fix is to stop treating [DONE] as the only valid finish signal. The client should also emit a terminal state when the stream closes or goes idle, and it should preserve any partial chunk instead of throwing it away.

const decoder = new TextDecoder();
const CR = String.fromCharCode(13);
const LF = String.fromCharCode(10);
const CRLF = CR + LF;

function watchSseStream(response, { onEvent, onDone, idleMs = 12000 } = {}) {
  if (!response.body) {
    throw new Error('ReadableStream responses are required for this harness.');
  }

  const reader = response.body.getReader();
  let buffer = '';
  let idleTimer;
  let finished = false;

  function finish(reason) {
    if (finished) return;
    finished = true;
    clearTimeout(idleTimer);

    emitCompleteFrames();

    const leftover = buffer.trim();
    if (leftover) {
      onEvent({ data: leftover, partial: true });
    }

    onDone({ reason, hadPartialFrame: leftover.length > 0 });
  }

  function resetIdle() {
    clearTimeout(idleTimer);
    idleTimer = setTimeout(() => finish('idle-timeout'), idleMs);
  }

  function emitCompleteFrames() {
    const separator = LF + LF;

    while (buffer.includes(separator)) {
      const boundary = buffer.indexOf(separator);
      const frame = buffer.slice(0, boundary);
      buffer = buffer.slice(boundary + separator.length);

      const data = frame
        .split(LF)
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join(LF);

      if (data && data !== '[DONE]') {
        onEvent({ data, partial: false });
      }
    }
  }

  function pump({ done, value }) {
    if (done) return finish('stream-closed');

    buffer += decoder.decode(value, { stream: true }).split(CRLF).join(LF);
    emitCompleteFrames();
    resetIdle();

    return reader.read().then(pump);
  }

  resetIdle();
  return reader.read().then(pump).catch(() => finish('stream-error'));
}
Enter fullscreen mode Exit fullscreen mode

This snippet is a harness, not a complete production parser. It deliberately treats an orderly close or idle timeout as terminal, and it surfaces a partial final frame as real information rather than waiting for a separator that will never arrive.

Keep screen-reader announcements out of the token loop

The browser-side fix is not only about showing text. If every token writes directly into a live region, VoiceOver and NVDA can turn one response into dozens of interruptions. The status region should announce a stable state, not a stream.

<section id='chat-status' aria-live='polite' aria-atomic='true'>
  Waiting for response.
</section>
Enter fullscreen mode Exit fullscreen mode
const status = document.getElementById('chat-status');
let lastAnnouncement = '';

function announce(state) {
  if (lastAnnouncement === state) return;
  lastAnnouncement = state;

  status.textContent = '';
  requestAnimationFrame(() => {
    status.textContent = state;
  });
}

watchSseStream(response, {
  onEvent({ data }) {
    appendToken(data);
  },
  onDone({ reason, hadPartialFrame }) {
    announce(
      hadPartialFrame
        ? 'Response stopped before completion.'
        : reason === 'idle-timeout'
          ? 'Response stopped after a long silence.'
          : 'Response complete.'
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

The screen reader now hears one outcome instead of every token. That distinction matters most when the stream fails, because the user needs to know that the response is incomplete and can retry.

Test the failure path, not just the success path

A happy-path test proves very little when the bug only appears after a dropped connection. I added a small matrix around the exact transition that failed.

  • Run curl -N before trusting any SDK or UI abstraction.
  • Throttle the connection to 50 ms latency and cut it off at about one third of the expected stream.
  • Confirm that the live region announces a single stable outcome in Chrome with NVDA and Safari with VoiceOver.
  • Capture the raw tail with tail -c 300 stream.log | xxd and record whether the final frame arrived.

Limits of this harness

  • It cannot restore tokens the server never sent. If the upstream model stops mid-thought, the client can only tell the user it stopped.
  • The idle timeout needs tuning. Set it too low and slow reasoning gets cut; set it too high and a dead connection still feels unresponsive.
  • A free server and shared model endpoint may restart or drop a long-lived session, so keep conversation state outside process memory.
  • This is not a replacement for official SDK retry behavior or durable server-side logging in regulated systems.

The harness is most useful when you can point it at a free endpoint and inspect the frames before adding UX on top. MonkeyCode's free model access and free server option makes that cheap to do, but the underlying lesson applies to any streaming provider: treat a 200 as the beginning of the stream contract, not the end.

Top comments (0)