跪拜 Guibai
← Back to the summary

Streaming AI Output in the Browser: A ReadableStream and Vue Walkthrough

Target audience: Developers learning modern frontend development who want to understand how "streaming output" is actually implemented.


1. Why do we need streaming output?

Have you ever encountered this situation: you open an AI chat app, type in a question, and then... just wait.

User: Tell a story about a Chinese dragon
        ↓
     [Waiting... waiting... waiting...]  ← 10 seconds pass
        ↓
     [The entire answer is returned at once]

It's like going to a restaurant where the chef only brings out all the dishes after cooking every single one — you're starving at the table. Streaming output is like bringing out each dish as soon as it's done:

User: Tell a story about a Chinese dragon
        ↓
     "In" → "a" → "distant" → "eastern land" → "there lived a" → "giant dragon" → ...
        ↑ Characters appear one by one in real time, like a typewriter

An analogy:

Like a water flow, a pipe is connected between the LLM server and the chatbot client, and generated tokens flow continuously toward the client like water.


2. Project skeleton — what do the files do?

First, a glance at the project structure (core files):

stream-demo/
├── index.html              ← Page entry point, the "socket"
├── package.json            ← Project config, dependency declarations
└── src/
    ├── main.js             ← Vue app startup, the "plug"
    ├── App.vue             ← Root component, core logic lives here
    ├── style.css            ← Global styles (global reset)
    └── components/
        └── HelloWorld.vue  ← Welcome page component (comes with Vite scaffolding)

Step 1: index.html — an empty shell

<div id="app"></div>
<script type="module" src="/src/main.js"></script>

Just a single <div id="app"></div>, empty. It's merely a mount point — where Vue's "territory" begins. What happens outside is not its concern; everything inside is taken over by Vue.

Step 2: main.js — plugging in the power

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')   // "Plug" the App component into #app

One line gets the entire Vue application running — createApp(App).mount('#app'), just like plugging a plug into a socket.

Step 3: App.vue — the protagonist enters

This is the soul of the project. A .vue file is divided into three parts:

┌────────────────────────────────┐
│  <template>   What you see     │
│  <script>     Data and logic   │
│  <style>      Styling          │
└────────────────────────────────┘

This is the component-based thinking — packaging HTML, CSS, and JS into an independent, reusable business unit. Facebook's web page is composed of over ten thousand components, assembled like Lego bricks to build the entire application.


3. Data binding — goodbye to manual DOM manipulation

The core philosophy of data binding is:

The template binds to data; no DOM programming is needed.

What does that mean? A comparison makes it clear:

// ❌ Traditional approach: manual DOM manipulation (a nightmare)
document.getElementById('result').innerHTML = 'Thinking...';
// Every time data changes, you must manually update the DOM.
// If 100 places use it, you write it 100 times.

// ✅ Vue data binding: declarative, automatic synchronization
const content = ref('');
content.value = 'Thinking...';  // Just change the data; the page updates automatically

Vue provides several data binding methods in actual code:

Syntax Code Example Purpose
{{ }} Interpolation {{ content }} Data → Page display
v-model Two-way v-model="question" Data ⇄ Input box, bidirectional sync
v-if Conditional v-if="Stream" Control element display/hide based on data

v-model deserves an extra mention — form elements are an exception. Normal data display is one-way (data → page), but input boxes need to pass user input back to the data, so two-way binding is required.


4. Core implementation — how streaming output is achieved

This is the complete flow of the update function inside App.vue:

4.1 Non-streaming mode (Stream = false)

User input → Send request → Wait for LLM to finish generating everything → Return entire JSON at once → Display
const data = await response.json();
content.value = data.choices[0].message.content;  // Assign all content at once

Simple and crude, but the wait is long.

4.2 Streaming mode (Stream = true)

This is the essence:

// Add stream: true in the request body
body: JSON.stringify({
  model: 'deepseek-chat',
  messages: [{ role: 'user', content: question.value }],
  stream: true,  // ← Key! Tell the server: send tokens one by one, don't batch them
})

// Then, instead of response.json(), use a "pipe" to read
const reader = response.body?.getReader();   // Get the reader
const decoder = new TextDecoder();            // Binary → Text decoder

let done = false;
while (!done) {                               // Loop to read
  const { value, done: doneReading } = await reader.read();
  done = doneReading;
  if (done) break;
  content.value += decoder.decode(value);     // Append and display immediately as soon as something is read
}

Represented as a diagram:

LLM Server                          Chatbot Client
   │                                      │
   │──── token1: "In" ──────────────────→ │ content.value += "In"
   │──── token2: "a" ───────────────────→ │ content.value += "a"
   │──── token3: "distant" ─────────────→ │ content.value += "distant"
   │──── token4: "eastern" ─────────────→ │ content.value += "eastern"
   │──── ...continues... ────────────────→ │ ...keeps concatenating...
   │──── [DONE] ────────────────────────→ │ Reading ends

The core is just two steps:

  1. Server-side: Accepts stream: true, outputs a token as soon as it's generated.
  2. Client-side: Uses a ReadableStream reader to continuously read, concatenating to content as soon as it's read.

Because content is reactive data (ref), the page updates automatically every time content is appended — the user sees a typewriter-like effect.


5. Complete data flow

Stringing the entire project together, a complete conversation process looks like this:

1. User inputs "Tell a story about a Chinese dragon"
   ↓  v-model two-way binding
2. question.value = "Tell a story about a Chinese dragon"
   ↓  Click submit button @click="update"
3. content.value = "Thinking..."  → Page displays "Thinking..."
   ↓
4. fetch() request to DeepSeek API
   - URL comes from .env.local (Vite automatically reads environment variables)
   - Request body carries stream: true
   ↓
5. Read the response body (ReadableStream)
   - decoder converts binary to text
   - while loop continuously calls reader.read()
   - Each time something is read, content.value += new content
   ↓
6. Page updates in real time {{ content }}
   → User sees a typewriter-like output effect

6. Technical key points checklist

This article covers all the key concepts:

Concept Explanation
Streaming Output Pushes tokens one by one in real time, without waiting for full generation
Vue SFC .vue file, the trinity of template + script + style
Componentization Pages are assembled from components, not from HTML tags
Mount Point <div id="app"> is Vue's entry anchor
Reactive Data Data wrapped by ref(), the page updates automatically when it changes
Data Binding {{ }} text interpolation, v-model two-way binding, v-if conditional rendering
Vite Scaffolding tool, helps read .env.local environment variables
ReadableStream Browser API, reads data chunks sent by the server one by one

7. Final words

To summarize in one sentence:

Crafting user experience is the responsibility of the frontend. Streaming output is the core experience of AI products and a must-know topic for frontend engineers.

Looking back now, implementing streaming output is actually not mysterious — it's just adding a stream: true to the request, then using a reader to loop-read on the response side. But it's this seemingly simple technique that transforms the user experience of an AI product from "anxious waiting" to "silky smooth enjoyment" 🎯