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:
- Server-side: Accepts
stream: true, outputs a token as soon as it's generated. - Client-side: Uses a
ReadableStreamreader to continuously read, concatenating tocontentas 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" 🎯