Skip to content

Testing That a Stream Closes Cleanly on the Happy Path

9 min read · updated August 11, 2026

A stream that ends is not the same as a stream that closed. The happy path is the case nobody writes a test for, because it obviously works — and it is where a leaked socket, a missing terminal frame or a client spinner that never stops comes from.

What the terminal frame is

It is different in each of the two wire formats most code meets, and the difference is the source of a specific class of bug when an endpoint speaks one and a client expects the other.

OpenAI chat completions. Each event is a data: line containing one JSON object with object: "chat.completion.chunk". The last chunk with a choices entry carries a non-null finish_reason. Then, if and only if the request set stream_options: {"include_usage": true}, one more chunk arrives whose choices is an empty array and whose usage holds the token totals for the whole request — OpenAI documents that usage is null on every chunk except that last one. Finally comes the literal line data: [DONE], which is not JSON and must not be handed to a JSON parser.

Anthropic Messages. There is no [DONE]. The stream ends with a named message_delta event carrying the final stop_reason and cumulative output tokens, then a message_stop event, as Anthropic’s streaming documentation sets out. A client written against OpenAI’s format and pointed at this one waits for a sentinel that will never arrive, and reports a hang rather than a completion.

Two things follow from that difference. First, if your endpoint normalises several providers into one downstream format, the terminal frame is something you are generating, not forwarding, and it needs its own test on every upstream shape you support — the Anthropic path has no [DONE] to pass through, so a pass-through implementation simply never terminates. Second, the finish reason and the terminal frame are different signals and both must be present. The finish reason tells a caller why generation ended; the terminal frame tells it that nothing more is coming. Code that treats one as implying the other breaks in both directions: a truncation with finish_reason: "length" is a complete stream, and a socket that closes after a perfectly ordinary content delta is not.

Four things clean means

  • The terminal frame was emitted, exactly once, last. Not once per content block, not before the final content delta.
  • It was properly framed. SSE frames end with a blank line. A terminal frame written without its trailing \n\n is a frame the client is still waiting to complete, and it is invisible in any test that reads the whole body and splits it.
  • The response body ended. The reader reaches done: true rather than blocking. This is a separate fact from the terminal frame — an endpoint that writes [DONE] and forgets res.end() satisfies the first and not this one, and the client shows a finished message with a live connection behind it.
  • Nothing was left running. No keepalive interval, no abort listener, no upstream reader still pulling.

The test

import { expect, test } from "vitest";

test("the happy path terminates the stream and the connection", async () => {
  const res = await fetch(endpoint, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
  });
  expect(res.ok).toBe(true);

  const frames: string[] = [];
  let buffer = "";
  const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();

  const finished = await Promise.race([
    (async () => {
      for (;;) {
        const { value, done } = await reader.read();
        if (done) return "closed";
        buffer += value;
        let i: number;
        while ((i = buffer.indexOf("\n\n")) !== -1) {
          frames.push(buffer.slice(0, i));
          buffer = buffer.slice(i + 2);
        }
      }
    })(),
    new Promise((r) => setTimeout(() => r("timeout"), 5000)),
  ]);

  // 3: the body ended rather than hanging.
  expect(finished).toBe("closed");
  // 2: no half-written frame was left in the buffer.
  expect(buffer).toBe("");
  // 1: the terminal frame is present once, and it is last.
  const terminals = frames.filter((f) => f.trim() === "data: [DONE]");
  expect(terminals).toHaveLength(1);
  expect(frames.at(-1)!.trim()).toBe("data: [DONE]");

  // and the frame before it reported why generation stopped.
  const last = JSON.parse(frames.at(-2)!.replace(/^data: /, ""));
  expect(last.choices[0].finish_reason).toBe("stop");
});

The Promise.race is the load-bearing part. Without it, an endpoint that never closes produces a test that hangs until the runner’s global timeout kills it, with a message about the test file rather than about the stream. With it you get expected "timeout" to be "closed", which names the bug.

Note what the test does not do: it never looks at the words in the answer. Every assertion here is about the envelope, so the same test runs unchanged against a fixture, against a different model, and against a provider that reworded its output overnight. That is what makes it worth having on the happy path — it is the rare streaming assertion with no maintenance cost attached to it.

Run it against a fixture and against a real provider call if you can afford one nightly. The fixture version gives you determinism and runs on every commit; the live version is the only thing that notices when a provider changes its terminal behaviour, which is the failure the whole page is insurance against. Keep them as the same test parameterised by base URL rather than as two files that drift.

Keepalives and comments

Long-running streams often send a periodic comment line — a line beginning with a colon, such as : ping — to stop intermediaries from closing an idle connection. Under the SSE grammar these are ignored by the client and carry no data. Anthropic’s format uses a named ping event for the same purpose.

Two assertions follow. First, comments must not be counted as content: a collector that treats every frame as a delta will report phantom chunks, and a UI that renders every frame will show blank lines. Assert that a stream containing injected comment frames produces the same text and the same delta count as one without. Second, the keepalive timer must be cleared on close — which brings you to the last assertion.

Proving nothing was left open

The tidiest way to test this is to make the handler tell you. Have your streaming handler increment a counter when it opens and decrement it in a finally, expose it in test builds, and assert it returns to zero after the response completes.

  1. In the handler, wrap the whole streaming body in try / finally. In the finally, clear the keepalive interval, cancel the upstream reader, and decrement the counter.
  2. In the test, read the response to completion as above, then assert the counter is zero. Use vi.waitFor rather than a bare assertion — the handler’s cleanup can run a tick after your reader sees done.
  3. Add the same assertion to the drop and cancel tests. A cleanup path that runs on success and not on abort is the common shape of this bug, and it is invisible until a process leaks handles under load.
  4. If you run Node with --detectOpenHandles-style diagnostics or Vitest’s teardown reporting, treat a warning about a lingering timer as a failure rather than noise. It is usually this.
The [DONE] sentinel, the empty-choices usage chunk and the message_stop event are provider surface and can change. Re-check them against the vendor documentation when this page is next revisited rather than trusting the strings above indefinitely.