Streaming AI Output in Vue 3: The ReadableStream Pipeline Every Frontend Dev Needs
Frontend Must-Know: AI Streaming Output Principles and Vue 3 Practice 🚀
Why can ChatGPT and DeepSeek spit out answers word by word like a typewriter? As a frontend engineer, this is precisely your responsibility.
1. First, Experience It: Streaming vs. Non-Streaming
Open an AI application and ask "Write me a story about a Chinese dragon," then click send:
- Non-streaming (one-time return): The interface is blank, a spinner turns for 10 seconds, and then "bam"—the entire article suddenly appears. You feel uneasy: Is it stuck? Is the network down?
- Streaming (token-by-token return): The text appears like a typewriter, popping out character by character. You watch and judge: "Hmm, the opening is good, keep reading."
The total time is roughly the same, but the experience is worlds apart. The reason is—
The user sees words popping out → "It's working" → The perceived waiting time is greatly reduced.
This is the core value of streaming output: not shortening absolute time, but shortening the user's perceived waiting time. This is also why almost all chatbot and AI assistant products use streaming output—it is the "first impression" of an AI product.
📌 Quick Knowledge Points
| Comparison Dimension | Non-Streaming Output | Streaming Output |
|---|---|---|
| Response Method | All generated, returned at once | Each generated token sent immediately |
| User Experience | Blank wait → sudden appearance | Character-by-character display, typewriter feel |
| Time to First Byte (TTFB) | Wait until fully complete | Almost instant |
| Frontend Handling | response.json() done in one line |
Requires ReadableStream + TextDecoder |
| Applicable Scenarios | Non-real-time scenarios | AI conversations, real-time push |
2. Underlying Principles—Connecting a Pipe 💧
2.1 The Water Pipe Analogy
The LLM server is a large reservoir, the chat client is your faucet. A water pipe (network connection) connects them in the middle.
- Non-streaming = The reservoir fills a large bucket with all the water, and when the bucket is full, it transports the whole thing to you. You wait dry, not knowing when the water will come.
- Streaming = The water pipe is turned on, water flows continuously, and you catch and use it as it comes. You can see every drop immediately as it arrives.
Large Language Models (LLMs) generate content token by token—each time only inferring the next most likely token, not "thinking up" the entire paragraph at once:
"I" → "I am" → "I am very" → "I am very happy"
Each generated token is immediately sent out through the "water pipe." The entire pipeline:
LLM token-by-token inference → Server sends immediately → Network transmission → Frontend reads chunk → Appends to interface
2.2 Protocol Agreement: stream: true
Streaming output is an agreement between the client and the server:
- Client includes
stream: truein the request body, meaning "I receive data in streaming mode" - Server, upon receiving this, sends each generated token immediately without waiting for full generation
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // 🔑 Just this one parameter controls streaming/non-streaming
})
2.3 Data Format: SSE (Server-Sent Events)
Streaming output uses the SSE protocol at the HTTP level. The raw data looks like this:
data: {"choices":[{"delta":{"content":"I"}}]}
data: {"choices":[{"delta":{"content":"am"}}]}
data: {"choices":[{"delta":{"content":"very"}}]}
data: [DONE]
Four rules:
- Each line starts with
data: - Each message is separated by a blank line
[DONE]is the end signal- One-way communication: Server → Client, the client does not need to send further requests
💡 Open Chrome DevTools → Network → Find this request → Look at the Response tab, the data "grows" segment by segment, rather than appearing all at once.
3. Why Build from Scratch? — Project Initialization in the Agent Era
Before writing code, let's talk about the ideological level.
3.1 Don't Write Vue Projects from Scratch
Entering the Agent development era, we don't need to start from a blank file writing index.html, App.vue. We should:
- Use
npm create vite@latestto generate a project scaffold with one click - Or pull a template project from GitHub
Leave the project initialization drudgery to tools, and save your energy for business logic.
3.2 Hot Reload
The Vite dev server provides hot reload capability: file modification → automatic partial refresh. Change HelloWorld.vue, and see the effect immediately in the browser without manually refreshing the page. This is the foundational experience of modern frontend development.
4. Vue 3 Quick Start (Beginner Friendly) 📗
Before looking at the core code, understand Vue 3 in 3 minutes.
4.1 The Three Blocks of a .vue Single File Component (SFC)
A .vue file is like a Lego brick, encapsulating HTML, CSS, and JS together to form a reusable Component.
┌────────────────────────────────┐
│ <template> Template / HTML │ ← Defines structure, can bind data {{ }}
│ </template> │ Can bind events @click
│ │
│ <script setup> Script / JS │ ← Defines data and business logic
│ </script> │ ref() creates reactive data
│ │
│ <style> Style / CSS │ ← Defines appearance
│ </style> │
└────────────────────────────────┘
A component is the smallest unit of work that makes up a page—no longer at the granularity of HTML tags, but at the granularity of "a functional building block."
4.2 Data-Driven Thinking
Old-school approach (manual DOM manipulation):
document.getElementById('result').innerHTML = 'New content'; // Manual change, tiring and error-prone
Vue approach:
<div>{{ content }}</div> <!-- Bind data. When data changes, the interface automatically updates -->
const content = ref(''); // Create a reactive data item
content.value = 'New content'; // Only change the data! Don't worry about the DOM! Vue updates the interface for you
This is data-driven views—you just manage the data, and the interface automatically follows.
4.3 Reactive
Data wrapped by ref() is reactive. ref(0) returns a RefImpl object, with the actual value stored in .value. When .value changes, all places on the page bound to this data automatically update locally.
It's the same principle as an Excel formula: change a source cell, and all cells referencing it automatically recalculate.
⚠️ Note: In
<script>, access data with.value(e.g.,content.value), but in<template>, Vue automatically unwraps it, so use{{ content }}directly.
4.4 One-Way vs. Two-Way Binding
In Vue, most data flow is one-way: Data → Interface. Using {{ }} interpolation:
<div>{{ content }}</div> <!-- One-way: data changes, div auto-updates -->
But form elements are an exception—users need to input content, and the input must be written back to the data layer. v-model does exactly this:
<input v-model="question" />
<!-- Two-way →
<!-- User types "Hello" → question.value automatically becomes "Hello" -->
<!-- question.value changed to "hi" → input box immediately shows "hi" -->
v-model does two things simultaneously: displays data (Data → Interface) + writes back input (Interface → Data).
5. Project Practice—Hand-Write a Streaming Chat 🔨
5.1 Project Structure
stream-demo/
├── index.html # Entry HTML, provides mount point #app
├── .env.local # 🔒 Environment variables (API Key goes here, don't commit!)
├── .gitignore # Git ignore rules
├── package.json # Vue 3.5 + Vite 8
├── vite.config.js # Vite config (registers Vue plugin)
├── readme.md # Notes
└── src/
├── main.js # App entry: createApp(App).mount('#app')
├── App.vue # Root component, wraps HelloWorld
├── style.css # Global styles (margin/padding reset)
├── 1.js # Encoding/decoding demo: TextEncoder / TextDecoder
└── components/
└── HelloWorld.vue # 🔥 Core component! All streaming output logic
5.2 Environment Variables: .env.local
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
VITE_DEEPSEEK_API_BASE_URL=https://api.deepseek.com
VITE_DEEPSEEK_MODEL=deepseek-v4-flash
Vite automatically reads variables starting with VITE_, accessed in code via import.meta.env.VITE_DEEPSEEK_API_KEY.
⚠️
.env.localmust be added to.gitignore! An API Key is like your house key; committing it to Git is like hanging the key on your door.
5.3 Core Code Line-by-Line Breakdown
Step 1: Define Three Reactive Data Items
import { ref } from 'vue';
const question = ref('Tell a story about a Chinese dragon'); // User's input question
const content = ref(''); // AI reply content
const stream = ref(true); // Whether to stream (default on, bound to checkbox)
stream defaults to true—because this is a streaming demo, it showcases the streaming experience by default.
Step 2: The update() Function—Send Request
const update = async () => {
if (!question.value) return; // Empty check
content.value = 'Thinking...'; // 🔔 Give user immediate feedback, indicating "I'm working on it"
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // 🔑 Streaming switch
})
});
// ...subsequent branching
}
A few key knowledge points:
BearerAuthentication:Authorization: Bearer sk-xxxis the standard API authentication format. "Bearer" literally means "ticket holder"—whoever holds this token can access, similar to a concert ticket, recognizing the ticket, not the person.- POST, not GET: POST places data in the encrypted request body, secure and without URL length limits. Suitable for transmitting long messages and sensitive keys.
content.value = 'Thinking...': This is a page state change. Before the LLM API call starts, give the user immediate feedback indicating "your request has been received, processing." This is a detail of excellent UX.
Step 3: Non-Streaming Branch (Simple)
if (!stream.value) {
const data = await response.json();
content.value = data.choices[0].message.content;
}
Traditional API call method: fetch → response.json() → get choices[0].message.content → assign. Done in one line, the downside is the user waits dry.
Step 4: Streaming Branch (Core!) 🔥
if (stream.value) {
content.value = ''; // Clear old content
const reader = response.body?.getReader(); // ① Get the "faucet"
const decoder = new TextDecoder(); // ② Create the "translator"
let done = false; // ③ Flag variable, controls loop
let buffer = ''; // ④ Buffer area
while (!done) {
const { value, done: doneReading } = await reader?.read();
done = doneReading;
// ⑤ Decode + concatenate buffer
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // Buffer has been concatenated into chunkValue, clear it
// ⑥ Split by line, filter out SSE "data:" lines
const lines = chunkValue.split('\n')
.filter((line) => line.startsWith('data: '));
// ⑦ Parse each data line, extract delta.content → append to content.value
}
}
This is the soul of the entire demo. Let's peel back the layers:
Layer ①: response.body?.getReader() — Get the Faucet
response.body is a ReadableStream object, the "water pipe" mentioned earlier. .getReader() returns a reader, equivalent to installing a faucet on the water pipe—each turn (calling .read()) lets out a chunk of data.
?. is the Optional Chaining operator: if response.body is null or undefined, it doesn't error, just returns undefined.
Layer ②: new TextDecoder() — The Translator
What flows in the pipe is binary data (Uint8Array, an array of 8-bit unsigned integers, each value 0-255). Machines can read it, but to human eyes, it's gibberish.
TextDecoder is the translator, converting binary bytes into human-readable UTF-8 text.
The project also has a standalone demo file src/1.js specifically for this:
// src/1.js — Encoding/Decoding Demo
const encoder = new TextEncoder();
const bytes = encoder.encode('你好'); // String → Uint8Array
console.log(bytes); // e.g., [228, 189, 160, 229, 165, 189]
const decoder = new TextDecoder();
const str = decoder.decode(bytes); // Uint8Array → '你好'
console.log(str); // 你好
💡
TextEncoderandTextDecoderare a pair—one encodes (String→Binary), one decodes (Binary→String). Streaming scenarios only use decoding.
Layer ③: The done Flag Variable
let done = false; // Initially false, becomes true after reading [DONE], loop exits
This is a state flag—controls when the while loop stops.
Layer ④: The buffer Cache
let buffer = '';
Why a buffer? Because network transmission is variable-length—a chunk can be cut off at any position. For example, one line of SSE data: {"choices"...} might be split in half, landing in two different chunks. The buffer is used to temporarily store "incomplete half-lines," waiting for the next chunk to arrive to piece them together.
Layer ⑤: Decode + Concatenate Buffer
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // Buffer has been concatenated into chunkValue, clear for next round
First, prepend any residual buffer from the previous round (if any) to the current decoded result, forming a complete segment. Then clear the buffer, as it has been used.
Layer ⑥: Split by Line + Filter data: Lines
const lines = chunkValue.split('\n')
.filter((line) => line.startsWith('data: '));
In SSE format, each line of useful data starts with data:. split('\n') splits by line, filter keeps only lines starting with data:, filtering out all blank lines and metadata lines.
Layer ⑦: Parse JSON, Extract Token
for (const line of lines) {
const dataStr = line.slice(6); // Remove "data: " prefix (6 characters)
if (dataStr === '[DONE]') continue; // End signal, skip
try {
const json = JSON.parse(dataStr);
const token = json.choices[0]?.delta?.content || '';
content.value += token; // 🔥 Append a token each time, triggering view update!
} catch (e) {
// JSON parse failed, ignore this line
}
}
What follows data: is a piece of JSON. We:
- Cut off the
"data: "prefix (6 characters) - Skip if encountering
[DONE](this is the end signal) JSON.parseto parse the object- Navigate the path
choices[0].delta.contentto extract the token text content.value += token— Append to the reactive data
Each time content.value changes, Vue's reactivity system automatically triggers an interface update—the user sees the text popping out character by character.
5.4 Template Section
<template>
<div class="container">
<div>
<label>Input:</label>
<input class="input" v-model="question" /> <!-- Two-way binding input box -->
<button @click="update">Submit</button> <!-- Click triggers update -->
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream"/> <!-- Two-way binding checkbox -->
</div>
<div>{{ content }}</div> <!-- One-way binding, display AI reply -->
</div>
</div>
</template>
Three binding methods at a glance:
v-model="question"— Two-way binding, user input ↔ datav-model="stream"— Two-way binding, checkbox checked ↔ data{{ content }}— One-way binding, data → interface display@click="update"— Event binding, click triggers function
5.5 CSS Layout
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem; /* Mobile adaptation: scales proportionally relative to html root element */
}
Uses Flexbox layout. CSS document flow is the basis of page layout: default arrangement is top-to-bottom, left-to-right. display: flex opens a new Formatting Context, allowing child elements to be arranged according to flex-direction: column (vertically).
The rem in font-size: 0.85rem is a relative unit—relative to the <html> root element's font size. The benefit is mobile adaptation: just change the root element font size, and the entire page's fonts scale proportionally.
6. Advanced: SSE vs WebSocket—High-Frequency Interview Question 🔥
| Comparison Dimension | SSE | WebSocket |
|---|---|---|
| Communication Direction | One-way (Server → Client) | Two-way (Full-duplex) |
| Protocol | Based on HTTP/HTTPS, simple | Independent protocol ws://, requires handshake upgrade |
| Reconnection | Browser built-in automatic reconnection | Requires manual implementation |
| Implementation Complexity | Low (EventSource or manual fetch) |
High |
| Applicable Scenarios | AI streaming, stock quotes, notifications | Chat rooms, collaborative editing, online games |
AI chat is a typical "client sends request → server continuously pushes reply"—one-way flow. SSE is perfectly suitable, no need for WebSocket's bidirectional capability.
7. Common Pitfalls and Best Practices ⚠️
7.1 Error Handling
try {
const response = await fetch(endpoint, { ... });
if (!response.ok) {
content.value = `Request failed: ${response.status}`;
return;
}
// ...streaming read...
} catch (error) {
content.value = `Network error: ${error.message}`;
}
7.2 Canceling Requests
A user might initiate a new request midway or close the page, requiring cancellation of the old request to free resources:
const controller = new AbortController();
fetch(endpoint, {
signal: controller.signal, // Bind signal
// ...
});
// Before user initiates a new request
controller.abort(); // Abort the old request
7.3 Compatibility of reader?.read()
The code uses reader?.read() instead of reader.read()—?. optional chaining protection. Older browsers might not support ReadableStream, writing this way prevents a white screen error. Feature detection is recommended for production environments.
7.4 Displaying a "Typing" Status
<div>{{ content }}<span v-if="isLoading" class="cursor">|</span></div>
.cursor { animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
A blinking cursor tells the user "AI is working," instantly improving the experience.
8. A Diagram Summarizing the Whole Article 🗺️
┌──────────────────────────────────────────────────────────────┐
│ Streaming Output Panorama │
│ │
│ DeepSeek Server │
│ ┌──────────────┐ stream: true │
│ │ Token-by-token │────── SSE ──────→ ┌─────────────────┐ │
│ │ generation │ data: {...} │ Vue 3 Frontend │ │
│ │ Send one as │ data: {...} │ │ │
│ │ generated │ data: [DONE] │ response.body │ │
│ └──────────────┘ │ .getReader() │ │
│ │ ↓ │ │
│ Non-Streaming (comparison) │ reader.read() │ │
│ ┌──────────────┐ │ ↓ │ │
│ │ Fully │──── JSON ────→ │ TextDecoder │ │
│ │ generated, │ {choices:[...]} │ ↓ │ │
│ │ one-time │ │ split+filter │ │
│ │ return │ │ data: lines │ │
│ └──────────────┘ │ ↓ │ │
│ │ JSON.parse │ │
│ │ ↓ │ │
│ │ content += token │ │
│ │ ↓ │ │
│ │ UI updates │ │
│ │ character by │ │
│ │ character │ │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────────┘
9. Three Core APIs at a Glance
The entire streaming output relies on these three APIs:
| API | Function | One Sentence |
|---|---|---|
response.body.getReader() |
Get stream reader | Install a faucet on the water pipe |
new TextDecoder() |
Create text decoder | Binary → Human-readable text |
reader.read() |
Read next data chunk | Turn the faucet to catch a cup of water |
Remember them, and you've mastered the core of streaming output.
10. Final Words
- Why: Optimize user-perceived waiting time, the core experience of AI products
- What:
stream: trueprotocol agreement + SSE data format + ReadableStream - How:
getReader()→TextDecoder→reader.read()loop → SSE parsing → JSON extraction → Vue reactive update - How to do it well: Buffer handling across chunks, error handling, request cancellation, blinking cursor
As a frontend engineer, streaming output is not only a must-answer interview question, but also a fundamental skill for building excellent AI products.
The water pipe is connected, the water is coming, how to elegantly catch and display the water—this is the bounden duty of the frontend.