跪拜 Guibai
← Back to the summary

The SSE Engineering Inflection Point: Why Your Frontend Needs a BFF Layer

The Engineering Inflection Point of SSE Streaming Output — Why You Need a BFF Layer

In the last article, we built a streaming output demo from scratch — fetching the DeepSeek API directly in the frontend, using ReadableStream + buffer + SSE parsing to render tokens onto the page one by one. The code ran, but it raised three soul-searching questions:

  1. The API Key is directly exposed in the frontend code — Open F12, and the Key in .env.local is visible at a glance. Is this really secure?
  2. All the streaming parsing logic is piled into the Vue component — buffer truncation recovery, SSE line parsing, JSON.parse fault tolerance... these low-level dirty jobs are mixed into the UI component. Is the code still maintainable?
  3. Cross-origin issues — The frontend runs on localhost:5173, the backend runs on localhost:3000, and the same-origin policy directly blocks your fetch requests.

This article is not a "sequel" to the previous one, but an engineering inflection point — we no longer let the frontend call the LLM API directly. Instead, we insert a BFF (Backend For Frontend) layer in the middle, letting the server solve the three problems of security, complexity, and cross-origin access. If you haven't read the previous article, it's recommended to understand binary streams, the SSE protocol, and the buffer mechanism first. This article builds on that foundation and moves up a level.


1. SSE — A One-Way Channel Where the Server Actively "Pushes" Data to the Frontend

1.1 What is SSE

SSE (Server-Sent Events) is a protocol that allows the server to unidirectionally push data to the browser.

WebSocket (Full-Duplex)                     SSE (Unidirectional Push)
┌──────────┐    ←→    ┌──────────┐    ┌──────────┐    ←     ┌──────────┐
│  Browser  │    ←→    │  Server   │    │  Browser  │    ←     │  Server   │
│          │    ←→    │          │    │          │    ←     │          │
└──────────┘    ←→    └──────────┘    └──────────┘    ←     └──────────┘
  Both sides can send messages             Only the server can send messages

Use cases: Chat, collaborative editing     Use cases: LLM streaming output, stock tickers, notifications
Protocol: ws:// upgrade                    Protocol: Plain HTTP, Content-Type: text/event-stream
Complexity: High (heartbeat, reconnect,    Complexity: Low (data: line + newline = one message)
frame protocol)

Thought: Why is LLM streaming output naturally suited for SSE rather than WebSocket?

Because the interaction pattern of an LLM is naturally one-way — the frontend sends one request (prompt), and the server continuously pushes generated tokens. The frontend does not need to send anything else to the server during this process. WebSocket's full-duplex capability is overkill here, and the extra handshake overhead, heartbeat maintenance, and frame protocol parsing become a burden instead.

1.2 SSE Data Format (Review)

We already dissected the SSE format in depth in the previous article. Here's a quick review with a diagram:

HTTP Response:
  Content-Type: text/event-stream
  Transfer-Encoding: chunked

  data: {"id":"xxx","choices":[{"delta":{"content":"From"}}]}

  data: {"id":"xxx","choices":[{"delta":{"content":"the"}}]}

  data: [DONE]

2. The BFF Layer — A "Middleman" Tailored for the Frontend

2.1 What is BFF?

BFF = Backend For Frontend. It is fundamentally different from a traditional backend (pure backend server):

Traditional Backend Server                  BFF Layer
┌────────────────────────┐            ┌────────────────────────┐
│ Java / Go / Node        │            │ Node (maintained by full-stack │
│ MVC architecture        │            │ frontend engineers)    │
│ CRUD APIs               │            │ Customized services for │
│ RESTful design          │            │ frontend interfaces    │
│ Focus: Stability,       │            │ Aggregate/trim backend │
│ concurrency, security   │            │ interfaces            │
│ Maintained by backend   │            │ Handle streaming data, │
│ engineers               │            │ encoding/decoding     │
└────────────────────────┘            │ Focus: Frontend experience, │
                                       │ development efficiency │
                                       │ Maintained by full-stack │
                                       │ frontend engineers     │
                                       └────────────────────────┘

The "MVC development pattern" (Model-View-Controller) mentioned in the README notes is the most classic design pattern for traditional backends:

  • Model — The data layer, responsible for interacting with the database and defining data structures
  • View — The presentation layer, which in traditional backends is usually an HTML page rendered by a template engine
  • Controller — The logic layer, which receives requests, calls the Model, and returns the View

After the separation of frontend and backend, the View layer was taken over by frontend frameworks (Vue/React), and the backend's View degenerated into a JSON serializer.

2.2 Why Add a BFF Layer? — Three Problems That Must Be Faced

The README notes describe the dilemma of full-stack frontend engineers this way:

"JS frontend, the backend has many requirements. If an interface needs to be changed, the full-stack frontend engineer writes a common Node service themselves to meet their own needs."

The real scenario behind this statement is: the frontend needs a new interface (for example, aggregating data from three backend microservices into one response), but the backend team's schedule is booked until next month. The full-stack frontend engineer's choice is — write a Node.js service themselves and put it in the middle. This is the origin of BFF.

In our streaming output scenario, the BFF layer solves three specific problems:

Problem 1: API Key Security
┌─────────────┐     fetch + API Key     ┌─────────────┐
│ Browser      │ ────────────────────→  │ DeepSeek API │
│ Frontend     │                         │             │
│ F12 → Source │  API Key exposed in     │             │
│ → Key leaked │  the client!            │             │
└─────────────┘                         └─────────────┘

After adding BFF:
┌──────────┐  fetch (no Key)  ┌──────────┐  fetch + Key  ┌──────────┐
│ Browser   │ ───────────────→ │ Node BFF │ ────────────→ │ DeepSeek │
│ Frontend  │                  │ (3000)   │               │  API     │
│ Can't see │                  │ Key on   │               │          │
│ the Key   │                  │ server   │               │          │
└──────────┘                  └──────────┘               └──────────┘

Problem 2: Streaming Parsing Complexity
  Frontend component 200 lines → 50% is streaming parsing logic → Code is hard to maintain
  Push streaming parsing down to BFF → Frontend only consumes the final data

Problem 3: Cross-Origin
  localhost:5173 → localhost:3000 → Blocked by same-origin policy
  Vite proxy config → Forward /api/* to BFF → Same origin achieved

3. Complete Dissection of the Server-Side Code — server.mjs

This is the most important file this time. The README notes define four steps for Node framework development: Install framework → Instantiate app → Listen on port → Define routes. Let's go through the code step by step.

3.1 Secure Loading of Environment Variables

import * as dotenv from 'dotenv';
// node's most common and simple development framework
// vite starts the http server, serving the frontend at 5173 
// 5173 frontend -> 3000 BFF backend -> deepseek 
// Frontend sends requests to the BFF layer to enjoy services web server backend private server status 3000 
// http server 

Line-by-line comment analysis:

import express from 'express';
// make our key more secure
// pure frontend, right-click view source, 
// fetch -> bff (apiKey)   
dotenv.config({
  path: ['.env.local', '.env']
});
Environment variable loading priority:
  .env.local  →  .env  →  process.env (system environment variables)
   (highest priority)      (second)       (fallback)

Comparison of Key storage locations:
  Previous article (pure frontend): .env.local → Vite compile → bundled into JS → visible via F12 ❌
  This article (BFF architecture): .env.local → dotenv load → process.env → only visible on server ✅

3.2 Create Server Instance and Define Routes

const app = express(); // server app 
const port = 3000;
// route 
app.get('/', (req, res) => {
  // continuously stream output 
  res.send('Hello World!'); // one-time send 
})

3.3 The BFF Route for Streaming Output — The Core

// streaming output bff layer, let the frontend call it
app.get('/stream', async (req, res) => {
  // prompt req parsing
  // fetch deepseek stream:true
  // llm 
  // console.log(req.query.request);
  // res.json({
  //   prompt: req.query.prompt,
  // })
  const { prompt } = req.query;
  const endpoint = 
    'https://api.deepseek.com/v1/chat/completions';

Line-by-line deep analysis:

3.4 Initiating a Streaming Request to the LLM

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Authorization': 
          `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`
      },
      body: JSON.stringify({
        model: 'deepseek-v4-flash',
        stream: true,
        messages: [{ role: 'user', content: prompt}]
      })
    })
    console.log(response.body) // ReadableStream
  } catch(err) {

  }
})

Line-by-line analysis:

3.5 Starting the Server

app.listen(port, () => {
  console.log(`Server started on port ${port}`);
})

// console.log(process.env.VITE_DEEPSEEK_API_KEY)
// llm request comes to bff
// backend is lightweight, just this one file, server side
// npm run dev vite service`
// node server.mjs runs the backend process
console.log('I am a BFF program hidden in a frontend project')

Line-by-line comment analysis:


4. Frontend Code — The "Slimming Down" of App.vue

After pushing the streaming processing logic down to the BFF, the frontend code becomes noticeably thinner:

<script setup>
// one-time -> streaming frontend -> bff -> llm request
fetch('/api/stream?prompt=hello')
  .then(res => res.json())
  .then(data => {
    console.log(data);
  })
</script>

// one-time -> streaming frontend -> bff -> llm request: This comment draws the evolution trajectory of the entire architecture:

The current fetch in App.vue is still using .then() chaining (rather than async/await), indicating this is a transitional version — first verify the communication link from frontend to BFF is working (hence only console.log), then integrate the complete streaming processing logic.

The template and style sections remain consistent with the previous article — v-model="question" for two-way binding the input box, v-model="stream" to control the streaming toggle, {{ content }} to display the AI's answer. The document flow comment in CSS (/* Document flow is the basis of page layout, top to bottom, left to right, flow layout */) was already analyzed in detail in the previous article and won't be repeated.


5. Cross-Origin and Proxy — The "Transfer Station" of vite.config.js

5.1 Same-Origin Policy: The Browser's Security Line of Defense

The README notes describe the cross-origin problem like this:

"As long as the domain, port, or protocol (http/https) is different, when fetching, it's cross-origin, same-origin policy"

Same-Origin Policy is the browser's most core security mechanism. It stipulates: JavaScript in a page can only access HTTP resources that are same-origin with the current page. "Origin" is defined by three parts:

Origin = Protocol + Domain + Port

http://localhost:5173  ← Vite Dev Server (frontend page is here)
http://localhost:3000  ← BFF Express Server (API is here)

Ports are different (5173 ≠ 3000) → Cross-origin → Browser blocks fetch request → CORS error

Without the same-origin policy, if you opened the website evil.com in your browser, it could use fetch to request bank.com's API (carrying the cookies you previously logged in with), and then steal your bank account information.

5.2 Vite Proxy: The "Legitimate Transfer" in the Development Phase

The README notes propose a solution:

"vite.config.js solution: change the request address to /api/stream, /api marks the request as a backend API interface, no more cross-origin, but 502. vite project proxy config, proxy the request and forward it out"

This note accurately describes a two-step troubleshooting process. Let's look at it against the vite.config.js code:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  // use vite to solve cross-origin
  // when a request is sent, vite will intervene
  server: {
    // proxy request
    proxy: {
      // frontend wants to go to the backend, requests starting with /api
      '/api': {
        // cross-origin, under the browser environment, the security of same-origin policy
        target: 'http://localhost:3000',
        secure: false,
        // /api/stream
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
})

Line-by-line comment deep analysis:

Complete request chain (step by step):

  1. Vue component executes
     fetch('/api/stream?prompt=Hello')
     
  2. Browser network stack
     Target URL: http://localhost:5173/api/stream?prompt=Hello
     Same-origin with the current page (host: localhost, port: 5173)
     → Same-origin policy: allowed ✅
     
  3. Vite Dev Server (:5173)
     Receives GET /api/stream?prompt=Hello
     Checks proxy rules: path starts with /api → match!
     Executes rewrite: /api/stream → /stream
     target: http://localhost:3000
     → Forwards request to http://localhost:3000/stream?prompt=Hello
     
  4. BFF Express Server (:3000)
     Receives GET /stream?prompt=Hello
     Route match: app.get('/stream', ...) 
     → Executes handler function
     
  5. BFF → DeepSeek API
     Attaches Authorization Header (reads Key from process.env)
     POST https://api.deepseek.com/v1/chat/completions
     body: { model: 'deepseek-v4-flash', stream: true, messages: [...] }
     
  6. DeepSeek API → BFF → Vite → Browser
     SSE streaming response passed back layer by layer

6. Analysis of the "502" Problem in the README Notes

There's a section in the README about the debugging process that's particularly worth expanding:

"change the request address to /api/stream, /api marks the request as a backend API interface, no more cross-origin, but 502"

502 Bad Gateway is one of the HTTP status codes, meaning "Gateway Error" — the proxy server (here, the Vite Dev Server) received an invalid response from the upstream server (the BFF Express Server).

When encountering a 502 during development, the troubleshooting approach is:

Frontend fetch('/api/stream')
       │
       ▼
Vite Proxy (:5173)  ← Receives request
       │
       ▼
Tries to forward to http://localhost:3000/stream
       │
       ▼
   Is the BFF started?
   ├── Not started → Connection refused → Vite returns 502
   └── Started → Process normally → Return response

Common causes of 502:

  1. BFF service not started — Only ran npm run dev (Vite), forgot to run node server.mjs (BFF)
  2. Port conflict — Port 3000 is occupied by another process
  3. Rewrite rule written incorrectly — For example, after path rewriting, it becomes a route that doesn't exist on the BFF

The README note "no more cross-origin, but 502" indicates that the same-origin policy has been resolved (Vite proxy turned /api/* into a same-origin request), and the problem lies in the connection between the proxy and the BFF. This is a positive development process — solve cross-origin first, then solve connectivity.


7. Architecture Panorama: Comparison of Three Chains

Let's put the previous article (pure frontend calling LLM), this article's current code (BFF forwarding but not processing the stream), and the ideal complete BFF solution into one diagram for comparison:

┌────────────────────────────────────────────────────────────────────┐
│                      Comparison of Three Architecture Solutions     │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  Solution 1: Pure Frontend Direct Call (Previous Article)          │
│  ┌──────────┐  fetch + Key + SSE Parse  ┌──────────┐              │
│  │ Vue      │ ──────────────────────→ │ DeepSeek │              │
│  │ Frontend │ ← SSE Binary Stream ──── │   API    │              │
│  │ :5173    │                         └──────────┘              │
│  └──────────┘                                                    │
│  Problems: Key exposed, bloated frontend                          │
│                                                                    │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  Solution 2: BFF Forwarding (Current Code in This Article)         │
│  ┌──────────┐  fetch     ┌──────────┐  fetch+Key  ┌──────────┐   │
│  │ Vue      │ ─────────→ │ Node BFF │ ──────────→ │ DeepSeek │   │
│  │ Frontend │            │ :3000    │             │   API    │   │
│  │ :5173    │            └──────────┘             └──────────┘   │
│  └──────────┘                                                    │
│  Key secure ✅  Frontend slimming in progress (code still iterating) │
│                                                                    │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  Solution 3: Complete BFF Solution (Next Step Goal)                │
│  ┌──────────┐ fetch  ┌──────────┐ fetch+Key ┌──────────┐         │
│  │ Vue      │ ─────→ │ Node BFF │ ─────────→ │ DeepSeek │         │
│  │ Frontend │        │          │            │   API    │         │
│  │ Only     │ ← ─ ─ │ Parse SSE│ ← ─ ─ ─ ─ │          │         │
│  │ receives │  SSE   │ Encode/  │  Binary    │          │         │
│  │ plain    │        │ Decode   │  Stream    │          │         │
│  │ text     │        │ buffer   │            │          │         │
│  └──────────┘        └──────────┘            └──────────┘         │
│  Key secure ✅  Frontend minimal ✅  Stream processing in BFF ✅    │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘

8. Summary: From "It Works" to "Engineered"

In the previous article, we implemented streaming output from scratch — the frontend directly leveraged the LLM API, using ReadableStream + buffer + SSE parsing to render tokens onto the page character by character. It proved that this thing can be done.

This article takes a step forward from "can be done" towards "done well":

Dimension Previous Article (Pure Frontend) This Article (Introducing BFF)
API Key Security ❌ Exposed in client JS ✅ Only exists in server process
Frontend Complexity ❌ Streaming parse + UI mixed together Gradually simplifying
Cross-Origin Problem N/A (direct to LLM) ✅ Vite proxy perfectly solves it
Architecture Scalability ❌ Change frontend for every new feature ✅ BFF handles uniformly, frontend only cares about data
Isomorphic Capability N/A ✅ Node/Browser share Web Streams API

After reading these two articles, you should have a clear evolution roadmap in your mind:

  1. Phase 1: Frontend directly connects to LLM → Understand the underlying principles of binary streams, SSE, and buffer mechanisms
  2. Phase 2: Introduce the BFF layer → Consolidate security, complexity, and cross-origin issues to the server side
  3. Phase 3 (Next step): BFF fully processes streaming data → Frontend only receives parsed plain text, code is as clean as calling a regular API

Engineering is not achieved in one step — it's built layer by layer. Each layer solves a specific problem, and each layer does not introduce unnecessary complexity. First make the code "runnable", then make the code "run elegantly".


This article and the previous one, "Stream is All You Need — From Binary Bytes to the Agent Era", form a complete streaming output series. The previous article focuses on underlying principles (binary encoding, SSE protocol, buffer truncation recovery, Vue reactive rendering), while this article focuses on engineering architecture (BFF layer design, API Key security, Vite Proxy, cross-origin solutions). Together, they cover the complete knowledge chain from underlying bytes to upper-level architecture.