跪拜 Guibai
← Back to the summary

Streaming AI Output from the Frontend: ReadableStream, SSE Parsing, and Vue 3

💧 From a Frontend Perspective, Thoroughly Understand AI Streaming Output — From "Connecting a Water Pipe" to Vue 3 in Practice

You open ChatGPT and ask a question. The cursor jumps, spitting out text character by character like a typewriter—what exactly is happening behind the scenes? This article takes you from the perspective of a frontend engineer, from the HTTP protocol layer all the way to writing Vue 3's ReadableStream, to thoroughly master "streaming output."


1. Have You Ever Experienced This Kind of Despair?

You open an AI chat product, type in a question, and click send…

Then the interface freezes.

The progress bar is spinning, but the screen is completely blank. 5 seconds, 10 seconds, 20 seconds pass. You start to wonder if the internet is down, if the backend has crashed, or even start questioning life itself.

Finally—at the 27th second—a huge chunk of text suddenly pops out all at once.

This is the experience of non-streaming output.

But when you use the web versions of ChatGPT or DeepSeek, you'll find it's completely different: the cursor jumps, and text comes out character by character, just like a typewriter. You can start reading the first half while it's still writing the rest, drastically shortening the brain's perception of waiting time.

The difference between these two experiences is the dividing line of Streaming Output.

There's a saying in AI product management circles: "Streaming output isn't an experience optimization; it's the experience baseline."

And who bears the responsibility for implementing this "baseline" experience? Frontend engineers.

In this article today, I'll take you from principles to code to thoroughly understand this matter.


2. Why Are LLMs So "Slow"?

Before discussing "how to be fast," first understand "why it's slow."

The inference process of a Large Language Model (LLM) is essentially performing token-by-token autoregressive generation. Every time a token is generated, it must be concatenated back into the context to predict the next token. This process involves:

So, if you wait for the LLM to completely generate the entire reply before returning it to you all at once, the waiting time = the sum of the inference time for all N tokens.

But the idea behind streaming output is very simple:

Don't wait for everything to be generated; give you a token as soon as one is generated.


3. The Underlying Layer of Streaming Output: What Exactly Is the HTTP Protocol Layer Doing?

3.1 An Analogy: Connecting a Water Pipe 🚰

Imagine this scene:

Non-streaming = The backend fills a whole bucket of water in a pool, then carries it over and pours it for you all at once. You have to wait for the bucket to fill—just waiting dry.

Streaming = A water pipe is connected between the backend and your browser. The tap is turned on (LLM starts inference), and water (tokens) flows gurgling through the pipe. You just hold a cup (the frontend UI) to catch it.

┌──────────────┐         ┌──────────────────┐
│  LLM Server  │  ═══▶  │  Chatbot Client  │
│  (Tap)       │  Pipe   │  (Your Browser)  │
│              │  token  │                  │
│  Generates a │ ──────▶ │  Receives one,   │
│  token, sends│         │  appends one,    │
│  one         │         │  displays in     │
│              │         │  real-time       │
└──────────────┘         └──────────────────┘

3.2 The "Agreement" Between Server and Client

For this whole setup to work, it relies on two agreements between the frontend and backend:

Role Agreement
Client (Browser) Include stream: true in the request body, telling the server: "I want streaming output"
Server (LLM API) Don't wait for full generation; write each inferred token to the response body immediately

The four words "stream: true" are the command you use to tell DeepSeek / OpenAI / any LLM API "Please talk to me in water pipe mode."

3.3 The Browser's Weapon: The ReadableStream API

In the browser, after getting a streaming response, the core weapon we use is a Web API called ReadableStream.

It is essentially a readable stream object, attached to response.body. You can get a "reader" via .getReader(), then loop .read() — each .read() returns a { value, done }:

Then we use TextDecoder to convert the binary into text—the core components for streaming reads are all assembled.


4. Hands-On Coding: Vue 3 + DeepSeek API Streaming Chatbot

Below, I'll use real code from a project to dissect each step. The source code structure comes from stream-demo/, a Vite + Vue 3 project.

4.1 Project Skeleton

npm create vite@latest stream-demo -- --template vue
cd stream-demo
npm install
stream-demo/
├── .env.local          # 🔑 API Key hidden here
├── index.html          # 📄 Entry HTML, defines mount point #app
├── src/
│   ├── main.js         # 🚀 Creates Vue app and mounts it
│   ├── App.vue         # ⭐ Core component (our protagonist today)
│   └── components/     # Reusable sub-components
├── vite.config.js      # ⚙️ Vite config (imports Vue plugin)
└── package.json        # 📦 Vue 3 + Vite 8

4.2 First, Solve the API Key: .env.local

Create .env.local in the project root; Vite will read it automatically:

VITE_DEEPSEEK_API_KEY=sk-your-deepseek-api-key

⚠️ Important: Only environment variables prefixed with VITE_ are exposed to the frontend code. This is a security mechanism. Also, .env.local must be added to .gitignore; never commit your key to Git!

Retrieve it in code like this:

const apiKey = import.meta.env.VITE_DEEPSEEK_API_KEY;

4.3 The Component Trio: <template> + <script setup> + <style>

A Vue .vue file consists of three parts—as clear as the instructions for a Lego brick:

<template>
  <!-- Dynamic HTML: can do data binding {{}} -->
</template>

<script setup>
  // JS Logic: reactive data, call API, handle stream
</script>

<style>
  /* Styles: exclusive CSS for this component */
</style>

The core idea of componentization is: Encapsulate HTML, CSS, and JS into an independent, reusable minimum unit of work. Facebook's webpage is built from over ten thousand components—write a component well, and it can be used everywhere.

4.4 Three Types of Reactive Data and Two-Way Binding

First, look at the <script setup> section:

import { ref } from 'vue'

const question = ref('Tell a story about a Chinese dragon')  // User's input question
const stream = ref(false)                                      // Whether streaming is enabled
const content = ref('')                                         // LLM's reply content

These three variables defined with ref() are reactive data. Reactive means: When the data changes, the page updates automatically.

Now look at how they are bound in <template>:

<!-- Two-way binding: user input → passed back to question -->
<input type="text" v-model="question" />

<!-- Two-way binding: check/uncheck → passed back to stream -->
<input type="checkbox" v-model="stream" />

<!-- One-way binding: when content changes, this refreshes automatically -->
<div>{{ content }}</div>

Here, an important Vue concept needs clarification:

Binding Method Syntax Data Flow Use Case
One-way binding {{ data }} Data → View Pure display content
Two-way binding v-model="data" Data ⇄ View Form elements (user also modifies data)

🧠 Why have both? Vue advocates Data Driven—you only need to care about modifying variable values, without manually manipulating the DOM (things like document.querySelector().innerHTML = xxx are unnecessary when writing Vue). But form elements are an exception: user input "flows backward from the view to the data," so v-model is needed for two-way binding.

4.5 The Core Method update(): Two Paths, Two Experiences

Now we come to the most core part of the entire article—the update() method. Triggered when the "Submit" button is clicked, it decides whether to take the "water pipe mode" or the "bucket mode."

const update = async () => {
  if (!question.value) return     // Guard against empty input
  content.value = 'Thinking...'

  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value   // 🔑 This single line decides which path to take
    })
  })

  // ═══════════════════════════════════════
  //  Fork in the road 👇
  // ═══════════════════════════════════════

  if (stream.value) {
    // 🚰 Water pipe mode: read chunk by chunk, typewriter effect
    content.value = ''
    const reader = response.body?.getReader()   // Get the reader
    const decoder = new TextDecoder()            // Binary → Text converter
    let done = false
    let buffer = ''

    while (!done) {
      const { value, done: doneReading } = await reader.read()
      done = doneReading
      if (value) {
        // Decode the binary data received this round
        buffer += decoder.decode(value, { stream: true })
        // SSE format parsing: split by line
        const lines = buffer.split('\n')
        buffer = lines.pop() || ''  // The last line might be incomplete, save for next round
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6).trim()
            if (data === '[DONE]') break  // Server says "finished"
            try {
              const json = JSON.parse(data)
              const delta = json.choices?.[0]?.delta?.content
              if (delta) content.value += delta  // Append to existing content
            } catch (e) {
              // Ignore fragments that failed to parse (incomplete data packets)
            }
          }
        }
      }
    }
  } else {
    // 🪣 Bucket mode: wait for full generation, get result all at once
    const data = await response.json()
    content.value = data.choices[0].message.content
  }
}

4.6 Line-by-Line Breakdown of the Streaming Part—This Is the "Heart" of the Entire Article

The streaming branch above, less than 30 lines of code, actually contains 5 layers of key mechanisms. Let me dissect them layer by layer:

Layer 1: Get the Readable Stream of the Response Body

const reader = response.body?.getReader()

After fetch() returns, response.body is a ReadableStream object. .getReader() returns a "reader"—only through it can we read chunk by chunk, rather than taking everything at once.

?. is the Optional Chaining operator—if response.body is null, it won't throw an error, just returns undefined. A good habit for defensive programming.

Layer 2: TextDecoder—The Translator

const decoder = new TextDecoder()

What the LLM server sends is a binary stream (Uint8Array), with human-readable text hidden inside. TextDecoder is this translator.

decoder.decode(value, { stream: true })

The second parameter { stream: true } is crucial: it tells TextDecoder "there is more data coming later, the current chunk might be incomplete"—this prevents it from erroneously parsing incomplete bytes split in the middle of a multi-byte character (like Chinese) into garbled text ``.

Layer 3: Buffer—The Jigsaw Puzzle

buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''

The streaming data from the LLM API uses the SSE (Server-Sent Events) format, where each message ends with \n. But the problem is—during network transmission, a \n might be split exactly between two data chunks.

So we need a buffer: append the newly read data to the end each time, split by \n, and leave the last incomplete segment for the next round to continue concatenation. This is the classic producer-consumer buffer pattern.

Layer 4: SSE Format Parsing

The streaming data returned by DeepSeek (and OpenAI-compatible APIs) looks like this:

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"中"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"国"}}]}

data: [DONE]

Each line starts with data: , followed by a JSON string. The content of the last line is [DONE], meaning "I'm finished speaking."

Parsing logic:

if (line.startsWith('data: ')) {
  const data = line.slice(6).trim()      // Remove the "data: " prefix
  if (data === '[DONE]') break            // End signal, exit loop
  const json = JSON.parse(data)
  const delta = json.choices?.[0]?.delta?.content   // Get the incremental token
  if (delta) content.value += delta       // Append to the page display
}

Layer 5: Reactive Rendering—Automatic Updates

Note that throughout the entire while loop, we did not write any document.querySelector() or .innerHTML.

We only did one thing:

content.value += delta

And because content is reactive data defined with ref(), Vue's reactivity system will automatically render the changes to the place in <template> where {{ content }} is written.

This is the so-called Data Driven: you just modify the data, and the view is automatically maintained by the framework. Say goodbye to manual DOM manipulation and bugs caused by state inconsistency.


5. A Diagram Connecting the Entire Process

User clicks "Submit"
      │
      ▼
  question.value
  stream.value
      │
      ▼
  fetch(DeepSeek API, { stream: true/false })
      │
      ├── stream = false (Bucket Mode)
      │     └─ await response.json()
      │        content.value = data.choices[0].message.content
      │        Wait 27 seconds → A huge chunk of text pops out 😓
      │
      └── stream = true  (Water Pipe Mode)
            └─ response.body.getReader()
               while (!done):
                 ① reader.read()      → Get binary chunk
                 ② decoder.decode()   → Binary → Text
                 ③ Buffer split '\n'  → Handle incomplete lines
                 ④ SSE parse "data:"  → Extract JSON
                 ⑤ content.value +=   → Append to page
                 {{ content }} auto-updates → Typewriter effect ✨

6. Several Pitfalls of Streaming Output—You Haven't Truly Learned Until You've Stepped in Them

Pitfall 1: Don't Forget to Handle the [DONE] Signal

If you don't check for [DONE], your while loop will try to JSON.parse('[DONE]') when it receives the last SSE message after the stream ends, directly throwing an exception.

Pitfall 2: Chinese Characters Being Cut Off

A Chinese character occupies 3 bytes in UTF-8. If you decode a Uint8Array directly from a middle position, you might cut exactly at the 2nd byte of a certain Chinese character—resulting in garbled text ``.

Solution: decoder.decode(value, { stream: true }) — that { stream: true } exists precisely for this.

Pitfall 3: Incomplete JSON Lines

Networks transmit in packets, so the SSE message you receive might only be half-arrived:

data: {"id":"chatcmpl-xxx","choices":[{"delta":  ← Cut off here

If you directly JSON.parse(), it will throw an exception. Solution: Wrap the parsing in a try-catch, throw the failed fragments back into the buffer, and try again after the next round completes it. Or simplify—just skip it and wait for the next complete data message.

Pitfall 4: No AbortController?

A user clicks "Send," gets impatient after 5 seconds, and clicks "Send" again—at this point, the previous request hasn't finished yet. If you don't cancel the previous request, a bizarre bug of data interleaving will occur.

let abortController = null

const update = async () => {
  // Cancel the previous unfinished request
  abortController?.abort()
  abortController = new AbortController()

  const response = await fetch(endpoint, {
    // ...
    signal: abortController.signal
  })
}

7. Summary: Three Sentences to Take Away

  1. The essence of streaming output is the HTTP protocol layer's "generate and transmit simultaneously"—the server sends a token as soon as it's generated, and the client appends it as soon as it's received, without waiting for the whole bucket of water to fill up.

  2. The frontend's core weapons are ReadableStream + TextDecoder + SSE parsing + buffer pattern. Combined with Vue's reactive data binding, less than 30 lines of code can achieve a ChatGPT-level typewriter effect.

  3. Reactive thinking > DOM manipulation thinking. When writing streaming output in Vue/React, your focus should be on "how to correctly concatenate streaming data onto a reactive variable," not "how to stuff text into a specific div."


Appendix: Complete Runnable Code

Below is the core logic abstracted from stream-demo/src/App.vue. Change the apiKey and it's ready to run:

<template>
  <div>
    <input type="text" v-model="question" placeholder="Enter your question" />
    <button @click="update">Send</button>
    <label>
      <input type="checkbox" v-model="stream" /> Streaming Output
    </label>
    <div class="output">{{ content }}</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const question = ref('Tell a story about a Chinese dragon')
const stream = ref(false)
const content = ref('')

const update = async () => {
  if (!question.value) return
  content.value = 'Thinking...'

  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value
    })
  })

  if (stream.value) {
    content.value = ''
    const reader = response.body?.getReader()
    const decoder = new TextDecoder()
    let done = false, buffer = ''

    while (!done) {
      const { value, done: doneReading } = await reader.read()
      done = doneReading
      if (!value) continue

      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() || ''

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue
        const data = line.slice(6).trim()
        if (data === '[DONE]') { done = true; break }
        try {
          const delta = JSON.parse(data).choices?.[0]?.delta?.content
          if (delta) content.value += delta
        } catch (e) { /* Ignore incomplete fragments */ }
      }
    }
  } else {
    const data = await response.json()
    content.value = data.choices[0].message.content
  }
}
</script>

🎯 The core code is barely 40 lines. Streaming output is not mysterious, not complicated; it just needs you to write it once yourself.

If this article helped you understand streaming output, give me a like 👍, bookmark ⭐, and comment 💬 so more frontend developers can see it—after all, the core experience of AI products is our frontend's home turf.