C.W.K.
Stream
Lesson 03 of 04 · published

TypeScript Streaming with fetch

~22 min · streaming, typescript

Level 0Downloader
0 XP0/41 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

The async generator pattern

In Node 20+ and modern browsers, the ReadableStream from fetch().body + TextDecoder + a manual newline-buffer is the canonical way to consume Ollama's NDJSON. Wrapping it in an async function* generator turns the stream into something you can iterate with for await.

The buffer pattern (don't skip this)

Network chunks don't align with JSON lines. A single read() might return:

  • '{"a":1,"b":2}\n{"c":3,' — last line is incomplete
  • '4}\n{"e":5}\n' — first part finishes the previous line

You must accumulate into a buffer, split on \n, parse all complete lines, and keep the partial tail in the buffer. This is the single most-bug-prone piece of TypeScript Ollama clients.

Code

Async generator with proper buffer handling·typescript
type Msg = { role: "system" | "user" | "assistant" | "tool"; content: string };

async function* streamChat(
  model: string,
  messages: Msg[],
  baseUrl = "http://localhost:11434",
): AsyncGenerator<{ content?: string; done?: boolean; raw?: any }> {
  const r = await fetch(`${baseUrl}/api/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model, messages, stream: true }),
  });
  if (!r.ok || !r.body) throw new Error(`Ollama ${r.status}`);

  const reader = r.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

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

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? ""; // keep incomplete tail

    for (const line of lines) {
      if (!line.trim()) continue;
      const chunk = JSON.parse(line);
      if (chunk.done) {
        yield { done: true, raw: chunk };
        return;
      }
      yield { content: chunk.message?.content ?? "", raw: chunk };
    }
  }
}

// Usage
for await (const tok of streamChat(
  "qwen2.5:7b",
  [{ role: "user", content: "Explain GGUF in 3 sentences." }],
)) {
  if (tok.done) {
    const r = tok.raw;
    const tps = r.eval_count / (r.eval_duration / 1e9);
    console.log(`\n[${r.eval_count} tokens @ ${tps.toFixed(1)} tok/s]`);
    break;
  }
  process.stdout.write(tok.content ?? "");
}
Browser version (same skeleton)·typescript
// Same code works in browsers when you've configured CORS or are proxying through your own backend.
// The only difference: use textContent instead of process.stdout, and append into a DOM node.

async function streamIntoDOM(target: HTMLElement, model: string, messages: Msg[]) {
  for await (const tok of streamChat(model, messages)) {
    if (tok.done) break;
    target.textContent += tok.content ?? "";
  }
}

External links

Exercise

Implement streamChat(model, messages) in TypeScript with proper buffer handling. Test it with two scenarios: a short answer (one network read covers the whole stream) and a long answer (many reads). Confirm both work and you never lose a partial line.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.