n4nAI

Vue streaming chat with ReadableStream and reactive refs

Implement Vue 3 streaming chat with ReadableStream and reactive refs to display LLM responses token-by-token without UI jank or complexity.

n4n Team3 min read652 words

Audio narration

Coming soon — every post will get a voice note here.

Building a chat interface that renders LLM output token-by-token requires more than a simple await. This guide implements vue readablestream reactive refs streaming in a Vue 3 component, so the UI updates incrementally as bytes hit the client, with no virtual DOM thrash.

We assume Vue 3 with <script setup> and TypeScript. The same pattern works in Nuxt client components.

Step 1: Scaffold reactive state

Create a component with the minimal state you need: a list of completed messages, the current input, and a dedicated ref for the in-flight response. Do not push partial tokens into the messages array on every chunk; that triggers repeated array reactivity and wastes cycles.

import { ref, shallowRef } from 'vue'

// Use shallowRef for the message list if messages are large objects.
const messages = shallowRef<{ role: string; content: string }[]>([])
const draft = ref('')
const streamingText = ref('')
const isStreaming = ref(false)
let abortController: AbortController | null = null

streamingText holds the partial assistant reply. When the stream ends, we commit it to messages.value and clear the temporary ref. This keeps the hot path cheap.

Step 2: Open the streaming request

Point a standard fetch at an OpenAI-compatible /v1/chat/completions endpoint with stream: true. Target an OpenAI-compatible /v1/chat/completions endpoint. For example, n4n.ai provides a single OpenAI-compatible route across 240+ models with automatic fallback when a provider is degraded, which simplifies client code. The browser hands you a ReadableStream on response.body.

async function send() {
  if (isStreaming.value || !draft.value.trim()) return
  isStreaming.value = true
  streamingText.value = ''
  abortController = new AbortController()

  const payload = {
    model: 'gpt-4o-mini',
    messages: [...messages.value, { role: 'user', content: draft.value }],
    stream: true,
  }

  try {
    const res = await fetch('https://api.example.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`,
      },
      body: JSON.stringify(payload),
      signal: abortController.signal,
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    await consumeStream(res)
  } catch (err) {
    if ((err as Error).name !== 'AbortError') {
      streamingText.value = `Error: ${(err as Error).message}`
    }
  } finally {
    commitMessage()
    isStreaming.value = false
    abortController = null
  }
}

We keep the API key in VITE_API_KEY for demo only; in production proxy through your own backend.

Step 3: Consume the ReadableStream into reactive refs

The response body is a ReadableStream<Uint8Array>. Wrap it with a TextDecoder and iterate. OpenAI-style streams emit Server-Sent Events: lines prefixed with data: , terminated by data: [DONE]. Parse each JSON delta and append choices[0].delta.content to streamingText.value.

async function consumeStream(res: Response) {
  const decoder = new TextDecoder()
  const reader = res.body!.getReader()
  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 partial line

    for (const line of lines) {
      const trimmed = line.trim()
      if (!trimmed.startsWith('data:')) continue
      const data = trimmed.slice(5).trim()
      if (data === '[DONE]') return
      try {
        const json = JSON.parse(data)
        const token = json.choices?.[0]?.delta?.content ?? ''
        if (token) streamingText.value += token
      } catch {
        // ignore malformed keepalive lines
      }
    }
  }
}

The buffer handling matters: TCP packets can split mid-line. Without it you will drop tokens or throw on partial JSON.

Step 4: Commit and render

When the stream finishes, move the temporary text into the message log. Use a computed or direct template binding to show both completed messages and the live streamingText.

function commitMessage() {
  if (!streamingText.value) return
  messages.value = [
    ...messages.value,
    { role: 'assistant', content: streamingText.value },
  ]
  streamingText.value = ''
}

Template:

<template>
  <div class="chat">
    <div v-for="(m, i) in messages" :key="i" :class="m.role">
      <pre>{{ m.content }}</pre>
    </div>
    <pre v-if="isStreaming" class="assistant">{{ streamingText }}</pre>
    <input v-model="draft" :disabled="isStreaming" @keyup.enter="send" />
  </div>
</template>

<style>
pre { white-space: pre-wrap; word-break: break-word; }
.assistant { color: #2a7; }
</style>

Use <pre> or white-space: pre-wrap because LLM output contains newlines and markdown that you do not want collapsed. Avoid deeply nested reactive wrappers on the streaming string; a plain ref<string> is already optimal.

Step 5: Abort and clean up

Users expect a stop button. Wire abortController.abort() to cancel the fetch and the underlying stream. The catch block already ignores AbortError; ensure you still commit whatever partial text arrived.

function stop() {
  abortController?.abort()
}

// In template: <button @click="stop" :disabled="!isStreaming">Stop</button>

Also handle tab close: not strictly necessary, browsers cancel fetches on navigation. If you keep a persistent connection (WebSocket), add onBeforeUnmount to close it. For ReadableStream over fetch, the abort controller is enough.

Step 6: Verify the integration

Run npm run dev, open the browser, and watch the Network tab.

  1. Submit a prompt. You should see a single POST to /v1/chat/completions with stream: true.
  2. In the Response panel, filter to “EventStream”. You must see multiple data: chunks arriving sequentially, not one blob at the end.
  3. The assistant message appears character-by-character (or token-by-token) in the UI. No full-page reflow, no frozen input.
  4. Click Stop mid-stream; the partial text remains visible and the input re-enables.
  5. Send a second message; the messages array includes prior turns, proving context retention.

If you see the entire reply only after several seconds, your gateway buffered the response or you awaited res.json(). The vue readablestream reactive refs streaming approach depends on unbuffered response.body.

Practical notes

  • For Nuxt, place this logic in a component with <client-only> or guard with import.meta.client to avoid SSR attempting to read response.body.
  • If you render markdown, debounce the parse: converting every token to HTML is expensive. Keep raw text in the ref and parse only on commit, or use a lightweight incremental renderer.
  • Per-token metering on the gateway side is invisible to the client; your streamingText length is not the billed count because providers count prompt + completion tokens differently.
  • Use shallowRef for messages as shown; deep reactivity on a growing array of strings wastes memory after the stream ends.

The pattern above is the smallest correct implementation. You can extend it with tool calls by inspecting delta.tool_calls, but the reactive ref discipline stays identical: accumulate into a temporary ref, commit once. That is how you keep vue readablestream reactive refs streaming smooth under real LLM latency.

Tagsvuereadablestreamstreamingchat-ui

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vue.js & nuxt llm streaming chat posts →