CodeGym /Các khóa học /ChatGPT Apps /Kênh luồng: SSE và HTTP/stream — khi nào và cách sử dụng

Kênh luồng: SSE và HTTP/stream — khi nào và cách sử dụng

ChatGPT Apps
Mức độ , Bài học
Có sẵn

1. Ở đâu các “luồng” xuất hiện trong kiến trúc ChatGPT App

Trước khi tranh luận cái nào tốt hơn — SSE hay HTTP-stream — sẽ hữu ích nếu hiểu trong stack của chúng ta luồng tồn tại ở đâu.

Có thể phân tách thành ba tầng.

Thứ nhất, tầng ChatGPT và mô hình. Bản thân mô hình đã stream câu trả lời theo token: bạn thấy văn bản phản hồi “được gõ” dần dần. Đó cũng là một luồng, nhưng hoàn toàn do OpenAI quản lý và không liên quan trực tiếp đến mã của bạn.

Thứ hai, tầng MCP. Khi ChatGPT kết nối tới máy chủ MCP của bạn, nó thường giữ một kết nối SSE: máy chủ đẩy vào đó các thông điệp MCP dạng JSON‑RPC (phản hồi và thông báo), còn ChatGPT gửi các yêu cầu đến một HTTP endpoint riêng, ví dụ /messages. Theo thuật ngữ MCP, đây là tầng vận chuyển cơ bản.

Thứ ba, tầng Apps SDK và backend của bạn. Widget React GiftGenius chạy trong sandbox của ChatGPT và giao tiếp với backend/MCP‑gateway của bạn qua HTTP: bằng fetch “thường”, bằng fetch với luồng (ReadableStream) hoặc qua đăng ký SSE (EventSource).

Điều quan trọng là không trộn các tầng này vào một mớ hỗn độn. Sự kiện MCP là “dây dẫn” giữa ChatGPT và các máy chủ; còn SSE/HTTP-stream giữa widget và HTTP‑backend của bạn — đó là đoạn đường thuộc về bạn.

Có thể minh họa bằng sơ đồ.

flowchart TD
  subgraph ChatGPT
    UI[ChatGPT UI + mô hình]
    W[GiftGenius Widget]
  end

  subgraph YourInfra[Hạ tầng của nhà phát triển]
    GW[MCP Gateway / Backend]
    MCP[MCP Server]
  end

  UI -- "tool-call / phản hồi\n(luồng token nội bộ)" --> W

  UI <-- "MCP over SSE\n(/sse + /messages)" --> MCP

  W <-- "HTTP / fetch / SSE / stream" --> GW

  GW <-- "JSON-RPC MCP" --> MCP

Hôm nay chúng ta sẽ tập trung vào mũi tên Widget ↔ Backend và phần nào nhớ lại rằng bản thân vận chuyển của MCP cũng dựa trên SSE.

Chính ở đoạn này — Widget ↔ Backend — chúng ta phải chọn cách giao tiếp: HTTP “thường” hay luồng. Ở phần tiếp theo, hãy xem vì sao HTTP “thường” ở đây nhanh chóng trở nên không đủ.

2. Vì sao một yêu cầu HTTP “thông thường” là chưa đủ

Mô hình HTTP chuẩn là “yêu cầu → một phản hồi”. Client hỏi gì đó, server trả lời một lần rồi đóng kết nối.

Với nhiều tác vụ, như vậy là đủ: lấy trạng thái hiện tại của job, lưu cài đặt người dùng, lấy danh sách quà tặng đã có sẵn trong cơ sở dữ liệu.

Nhưng ngay khi bạn làm một tác vụ dài, mọi thứ bắt đầu kèn kẹt.

Hãy tưởng tượng GiftGenius, nơi ứng dụng:

  • thu thập tín hiệu từ nhiều nguồn (lịch sử mua hàng, wishlist, mạng xã hội),
  • chạy dữ liệu qua vài yêu cầu LLM,
  • xây dựng xếp hạng cá nhân từ hàng trăm ứng viên.

Tất cả có thể mất hàng chục giây. Nếu bạn giữ một yêu cầu HTTP “thường” trong 40 giây và im lặng, trải nghiệm sẽ như trình duyệt cũ: người dùng nhìn spinner quay và đoán ứng dụng đã chết hay vẫn đang “nghĩ”.

Ngoài UX, còn có các vấn đề kỹ thuật thuần túy:

  • timeout ở phía ChatGPT, Vercel, proxy;
  • không thể gửi tiến độ, partial results, v.v.;
  • không thể xử lý đúng việc rớt kết nối và phục hồi.

Vì thế, giải pháp tự nhiên là: chuyển từ một phản hồi lớn sang một luồng các mảnh nhỏ, để server có thể gửi theo từng đợt khi sẵn sàng.

Các mảnh nhỏ này có thể là:

  • sự kiện (job.progress, job.completed) — thuộc SSE;
  • các phần của một payload lớn (văn bản báo cáo, các dòng NDJSON với quà tặng) — thuộc HTTP-stream.

3. SSE (Server‑Sent Events): đăng ký sự kiện

Bắt đầu với SSE, vì nó “gần” MCP hơn: chính MCP trên HTTP sử dụng kết nối SSE để đẩy sự kiện từ server đến client.

Mô hình SSE một cách dễ hiểu

SSE là một giao thức trên nền HTTP thông thường:

  1. client mở yêu cầu GET tới endpoint phản hồi với Content-Type: text/event-stream;
  2. server không đóng kết nối mà định kỳ ghi vào đó các dòng như sau:
event: job.progress
data: {"jobId":"123","percent":40}

event: job.completed
data: {"jobId":"123","resultCount":12}
  1. phía trình duyệt dùng EventSource, thứ mà:
    • tự theo dõi việc khởi động lại kết nối;
    • phân tích cú pháp định dạng event: + data: + hai lần xuống dòng;
    • gọi các handler onmessage / addEventListener("job.progress", ...).

Điểm mấu chốt: kênh là một chiều. Chỉ server gửi sự kiện cho client. Client không gửi dữ liệu qua kết nối này.

Với ChatGPT Apps, mô hình này rất phù hợp khi widget chỉ muốn “đăng ký” theo jobId và phản ứng với tiến độ cũng như việc hoàn tất tác vụ.

Ví dụ mini về SSE endpoint trong Next.js 16

Giả sử chúng ta có một route handler cho sự kiện tiến độ của Job:

app/api/gift-jobs/[jobId]/events/route.ts

import { NextRequest } from "next/server";

export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
  const jobId = params.jobId;

  const stream = new ReadableStream({
    start(controller) {
      // Tiện ích gửi sự kiện SSE
      const send = (event: string, data: unknown) => {
        const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(new TextEncoder().encode(payload));
      };

      send("job.started", { jobId });

      let percent = 0;
      const interval = setInterval(() => {
        percent += 20;
        if (percent >= 100) {
          send("job.completed", { jobId, totalGifts: 10 });
          clearInterval(interval);
          controller.close();
        } else {
          send("job.progress", { jobId, percent });
        }
      }, 1000);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream", // đây là nơi chúng ta thiết lập SSE
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

Đây chỉ là mô phỏng: mỗi giây phần trăm tăng và cuối cùng nhận job.completed. Sau đó bạn sẽ thay timer này bằng sự kiện thực từ worker/hàng đợi, nhưng sơ đồ chung vẫn vậy.

Client: đăng ký SSE trong widget GiftGenius

Bên trong widget React, ta có thể đăng ký luồng này khi có jobId. Nhắc lại, API của widget chạy trong sandbox của ChatGPT, nhưng EventSource ở đó vẫn có sẵn như trong trình duyệt thông thường.

import { useEffect, useState } from "react";

export function GiftJobProgress({ jobId }: { jobId: string }) {
  const [percent, setPercent] = useState(0);

  useEffect(() => {
    const url = `/api/gift-jobs/${jobId}/events`;
    const es = new EventSource(url);

    es.addEventListener("job.progress", (event) => {
      const data = JSON.parse((event as MessageEvent).data);
      setPercent(data.percent);
    });

    es.addEventListener("job.completed", () => {
      setPercent(100);
      es.close();
    });

    es.onerror = () => {
      // có vấn đề kết nối, thử kết nối lại
    };

    return () => es.close();
  }, [jobId]);

  return <div>Tiến độ chọn quà: {percent}%</div>;
}

Giờ bạn có thể kết nối điều này với MCP tool. Công cụ start_gift_job trả về jobId, còn trong ToolOutput của widget bạn chỉ việc render GiftJobProgress.

Tự động kết nối lại và Last‑Event‑ID

Theo chuẩn, EventSource sẽ tự cố gắng kết nối lại nếu kết nối bị rớt. Server có thể dùng trường chuẩn SSE id: trong sự kiện, còn client — header Last-Event-ID — để sau khi kết nối lại có thể bắt kịp các sự kiện bị bỏ lỡ.

Với GiftGenius đơn giản, bạn tạm thời chưa cần triển khai id: hay định danh sự kiện riêng, và chỉ chấp nhận một “khoảng rơi” nhỏ của tiến độ khi reconnect. Nhưng trên production, đặc biệt khi tải cao, bạn sẽ cần:

  • thêm trường chuẩn id: vào mỗi sự kiện SSE để client có thể gửi Last-Event-ID khi kết nối lại;
  • đưa vào event_id mang tính ứng dụng trong payload của sự kiện và dựa vào nó để xử lý idempotent ở client/backend.

Điều này gắn trực tiếp với tính idempotent: ngay cả khi cùng một job.progress tới hai lần, handler, khi thấy event_id quen thuộc, sẽ không thực hiện lại các tác dụng phụ.

Kết quả là SSE cho phép ta đăng ký thuận tiện các sự kiện quanh jobId với tự động kết nối lại và kiểm soát trùng lặp thông qua định danh sự kiện. Giờ hãy chuyển sang kiểu luồng thứ hai — khi ta có một yêu cầu nhưng phản hồi rất lớn và muốn trả dần theo phần.

4. HTTP‑streaming: trả dần cho một yêu cầu

Nếu SSE là “đăng ký các sự kiện độc lập”, thì HTTP‑streaming là “một yêu cầu, một phản hồi, nhưng phản hồi được kéo dài theo thời gian và đến theo từng chunk”.

Đây chính là cơ chế bạn thấy khi dùng OpenAI API với stream : true: server gửi các JSON chunk (thường ở định dạng SSE, nhưng logic là “một yêu cầu ↔ luồng phản hồi từng phần”), còn client ghép chúng thành văn bản cuối cùng.

Trong API của bạn, có thể làm điều tương tự cho:

  • các báo cáo văn bản lớn (ví dụ giải thích logic chọn quà),
  • các danh sách quà dài (stream từng phần, thay vì bắt người dùng chờ).

Endpoint HTTP‑stream đơn giản trong Next.js

Giả sử cần tạo “giải thích” cho kết quả chọn quà, nơi LLM viết một văn bản dài. Chúng ta muốn stream nó vào widget ngay khi sinh ra.

app/api/gift-report/route.ts

import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      controller.enqueue(encoder.encode("Bắt đầu phân tích...\n"));

      // Ở đây có thể là sinh LLM thật với các chunk
      for (const line of ["Thu thập sở thích...\n", "Tính toán ngân sách...\n", "Khuyến nghị cuối cùng...\n"]) {
        await new Promise((r) => setTimeout(r, 1000));
        controller.enqueue(encoder.encode(line));
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Transfer-Encoding": "chunked", // Ở đây chúng ta thiết lập rằng đây là HTTP/stream
    },
  });
}

Về kỹ thuật, Next tự quản lý mã hóa chunked; điều quan trọng là bạn trả về ReadableStream.

Đọc HTTP‑stream trong widget qua fetch

Ở phía client (bên trong widget) có thể đọc luồng như sau:

async function fetchReport(setText: (s: string) => void) {
  const res = await fetch("/api/gift-report", { method: "POST" });
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();

  let acc = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    acc += decoder.decode(value, { stream: true });
    setText(acc); // cập nhật UI khi dữ liệu đến
  }
}

Và một component bọc:

import { useState } from "react";

export function GiftReport() {
  const [text, setText] = useState("");

  return (
    <div>
      <button onClick={() => fetchReport(setText)}>Tạo báo cáo</button>
      <pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
    </div>
  );
}

Đây là mẫu điển hình: một yêu cầu POST tới /api/gift-report, còn phản hồi — là luồng văn bản bạn hiển thị dần dần.

Stream JSON, không phải văn bản

Thường bạn sẽ muốn stream không phải chuỗi mà là JSON object. Định dạng phổ biến nhất — NDJSON (Newline‑delimited JSON): mỗi sự kiện — là một dòng JSON và kết thúc bằng ký tự \n.

Ví dụ phía server:

const stream = new ReadableStream({
  async start(controller) {
    const encoder = new TextEncoder();
    for (let i = 0; i < 5; i++) {
      const chunk = { type: "gift", index: i, name: `Quà tặng #${i}` };
      controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
      await new Promise((r) => setTimeout(r, 500));
    }
    controller.close();
  },
});

Client đọc bằng TextDecoder, tách theo \n và parse các JSON object riêng lẻ.

5. SSE vs HTTP‑stream: khác nhau ở đâu và chọn thế nào

Tới lúc này bạn có lẽ đã có trực giác, nhưng hãy cố định lại dưới dạng một bảng nhỏ.

Đặc điểm SSE (Server‑Sent Events) HTTP‑stream (chunked)
Bên khởi tạo Client gọi GET và đăng ký Client gửi yêu cầu (GET/POST), server stream phản hồi
Hướng Chỉ server → client Phản hồi của server cho một yêu cầu cụ thể
Ngữ nghĩa Đăng ký luồng sự kiện (pub/sub) Phản hồi từng phần cho một yêu cầu
Giao thức tích hợp Có (event:, data:, id:, v.v.) Không; bạn tự chọn định dạng (chuỗi, NDJSON, JSON)
API phía client EventSource fetch + ReadableStream / response.body
Hỗ trợ reconnect Tích hợp sẵn (EventSource, Last-Event-ID) Cần tự triển khai
Tình huống điển hình Tiến độ, trạng thái, notifications theo jobId Streaming văn bản, phản hồi JSON lớn, output LLM

Nếu đơn giản hóa thành “quy tắc ngón tay cái” (cẩn thận, đừng cực đoan):

  • bạn có job và nhiều sự kiện xung quanh nó → SSE;
  • bạn có một tool‑call trả về kết quả lớn và muốn hiển thị dần → HTTP‑stream.

Với GiftGenius điều này có nghĩa: SSE — cho progress bar trực tiếp và trạng thái chọn quà; HTTP‑stream — cho phần tóm tắt văn bản dài hoặc tải dần danh sách quà dài.

6. Ghép nối với MCP và GiftGenius như thế nào

Nhắc lại sơ đồ ở đầu bài: mô hình ↔ MCP ↔ widget ↔ backend. Chúng ta đã xem các luồng ở tầng widget ↔ backend, giờ quay lại một bước và phân định rõ chỗ nào là MCP, chỗ nào là “chỉ HTTP”.

MCP xác định cách ChatGPT (với vai trò MCP client) giao tiếp với MCP server của bạn. Để làm điều đó có một tầng vận chuyển, trong đó:

  • ChatGPT mở kết nối SSE tới /sse và nhận theo đó các thông điệp MCP (phản hồi, thông báo, sự kiện);
  • ChatGPT gửi các yêu cầu MCP (call_tool, list_tools, v.v.) tới /messages, thường là POST với JSON‑RPC.

Tầng này bạn đã đi qua khi kết nối GiftGenius với ChatGPT.

Giờ, khi ta thêm tác vụ bất đồng bộ và luồng UX vào widget, sẽ có hai phương án kiến trúc.

Phương án thứ nhất — “MCP thuần”: MCP server tự sinh các sự kiện job.progressjob.completed; ChatGPT nhận chúng qua MCP‑SSE; sau đó mô hình tự gọi widget của bạn với ngữ cảnh đã cập nhật, và widget render tiến độ mà không cần trực tiếp nói chuyện với backend. Đây là con đường “chuẩn” dùng MCP events.

Phương án thứ hai — lai: MCP tool start_gift_job tạo tác vụ và trả về jobId; widget nhận jobIdtự giao tiếp với backend qua HTTP, đăng ký endpoint SSE /api/gift-jobs/{jobId}/events và, khi cần, yêu cầu HTTP‑stream cho báo cáo. Phía MCP khi đó không có gì đặc biệt.

Trong khóa học, chúng ta đi theo con đường lai: nó phù hợp hơn với App Router/Next và dễ debug cục bộ hơn. Sau này bạn có thể chuyển dần sang “thông báo MCP thuần” khi đã quen tay.

7. Reconnect, timeout và những thực tế khác của mạng

Mọi thứ nghe có vẻ lý tưởng: mở SSE hoặc stream, mọi thứ chảy, sự kiện tới, UX rạng rỡ. Trong đời thực, mạng thích cắt kết nối vào lúc không ngờ tới, còn hạ tầng — đặt timeout.

Điều gì có thể trục trặc

Với SSEHTTP-stream, sớm muộn bạn sẽ gặp:

  • timeout rỗi (idle) ở proxy: “nếu không có gì truyền qua kết nối trong N giây — đóng”;
  • khởi động lại backend của bạn (deploy, sự cố);
  • mạng không ổn định ở phía người dùng (đặc biệt trên di động).

Điều đó bình thường; quan trọng là sẵn sàng cho nó, thay vì hy vọng “sẽ ổn”.

Chiến lược cho SSE

SSE có nhiều ưu thế chính ở vùng này:

  • EventSource tự động kết nối lại với một khoảng trễ;
  • bạn có id:Last-Event-ID để bắt kịp sự kiện.

Bộ thực hành tối thiểu:

  1. Ở server định kỳ gửi thứ gì đó giống heartbeat để kết nối không bị xem là hoàn toàn rỗi. Có thể là một sự kiện riêng event: ping hoặc đơn giản là bình luận : keep-alive.
  2. Ở client, trong onerror hiển thị trạng thái dễ hiểu như “Có vấn đề kết nối, đang thử kết nối lại…”, thay vì làm hỏng cả widget.
  3. Khi kết nối lại, nếu bạn dùng id:, chỉ trả về từ server các sự kiện mới sau ID đó. Với GiftGenius, có thể bắt đầu không cần id: và chỉ “xây lại” trạng thái theo job.progress/job.completed mới nhất.

Chiến lược cho HTTP‑stream

HTTP‑stream là một yêu cầu, nên khi đứt, về bản chất bạn phải bắt đầu lại:

  • nếu bạn stream báo cáo văn bản, có thể chỉ cần thông báo người dùng “Không thể nhận toàn bộ báo cáo, hãy thử lại” và bắt đầu lại;
  • nếu bạn stream dữ liệu có cấu trúc (NDJSON), có thể nghĩ về cơ chế resume: ví dụ truyền trong yêu cầu offset hoặc cursor để tiếp tục từ đó.

Ban đầu đừng làm phức tạp: nếu stream phản hồi bị đứt trước khi xong — hiển thị phần đã có và một nút “Tiếp tục tạo báo cáo”, nút này sẽ gửi yêu cầu mới.

Điều chính — đừng để người dùng ở trạng thái “chờ mãi mãi”.

8. Áp dụng cho GiftGenius: kịch bản từ đầu đến cuối

Giờ gom lại mọi thứ đã bàn về SSE, HTTP‑stream và hai phương án kiến trúc với MCP, trên kịch bản thực của GiftGenius — từ yêu cầu người dùng đến báo cáo hoàn chỉnh.

Người dùng trong ChatGPT viết: “Hãy chọn quà cho người hâm mộ board game, ngân sách đến 100 đô la”. Mô hình quyết định gọi GiftGenius. Ứng dụng/agent thực hiện tool‑call start_gift_job tới MCP server của bạn. Server:

  • ghi job vào CSDL;
  • đưa job vào hàng đợi nội bộ (chi tiết về hàng đợi và worker — ở bài sau; giờ tạm coi là “ai đó” sẽ thực thi);
  • trả về jobId đồng bộ trong phản hồi của tool‑call.

Widget GiftGenius nhận ToolOutput với jobId và render component:

function GiftGeniusRoot({ jobId }: { jobId: string }) {
  return (
    <div>
      <h2>Đang tìm quà tặng phù hợp...</h2>
      <GiftJobProgress jobId={jobId} />
      <GiftReport />
    </div>
  );
}

Component GiftJobProgress đăng ký SSE /api/gift-jobs/{jobId}/events và vẽ tiến độ. Mỗi job.progress cập nhật phần trăm, job.completed — đặt 100% và có thể bật nút “Hiển thị báo cáo chi tiết”.

Component GiftReport khi click nút sẽ gửi POST /api/gift-report (truyền jobId) và hiển thị dần báo cáo văn bản khi server trả các chunk HTTP‑stream.

Khi đứt kết nối SSE, widget hiển thị cảnh báo nhẹ, còn EventSource cố gắng kết nối lại. Khi gặp vấn đề với stream báo cáo, người dùng thấy phần báo cáo đã có và nút “Tiếp tục tạo” hoặc “Thử lại”.

Từ góc nhìn ChatGPT và MCP:

  • MCP thấy tool‑call start_gift_job và có thể sau đó là các notification về trạng thái job;
  • UX quanh luồng chủ yếu được hiện thực ở tầng HTTP giữa widget và backend của bạn.

9. Lỗi thường gặp khi làm việc với SSE và HTTP‑stream

Lỗi №1: coi SSE và HTTP‑stream là “cùng một thứ”.
Đúng là bên dưới chúng dùng HTTP và phản hồi chunked, nhưng ngữ nghĩa rất khác nhau. SSE — là đăng ký các sự kiện độc lập, có thể đến bất cứ lúc nào và client không biết trước. HTTP‑stream — là một phản hồi cụ thể, được dàn trải theo thời gian. Nếu cố gắng hiện thực đăng ký cho nhiều jobId qua một HTTP‑stream, bạn sẽ phải tự phát minh giao thức trên byte, tức là xây lại nửa cái SSE.

Lỗi №2: bỏ qua tự động reconnect của SSE và không nghĩ về idempotent.
Nhiều người viết SSE server “đơn giản”: chỉ gửi data: ... mà không thêm id: chuẩn (cho Last-Event-ID) cũng như event_id mang tính ứng dụng trong thân sự kiện. Sau đó, ngay lần rớt kết nối và kết nối lại đầu tiên, các bản sao sự kiện bắt đầu sinh sôi. Không có event_id được thiết kế và logic “tôi đã thấy sự kiện này”, handler ở client có nguy cơ cập nhật trạng thái hai lần, hiển thị job.completed hai lần hoặc tệ hơn là trừ tiền/cộng thưởng hai lần.

Lỗi №3: gửi mỗi thay đổi nhỏ của worker thành một sự kiện SSE riêng.
Nếu bạn gửi tiến độ tác vụ qua SSE mỗi mili giây, bạn sẽ đánh gục mạng và client, chứ không mang lại hoạt ảnh mượt mà cho người dùng. Hợp lý hơn là gom cập nhật và gửi tiến độ, ví dụ, mỗi 200500 ms hoặc khi thay đổi giai đoạn. Chủ đề throttling và backpressure sẽ còn bàn tiếp, nhưng ngay lúc này đã nên nghĩ về tần suất sự kiện.

Lỗi №4: làm giao thức phức tạp trên HTTP‑stream mà không có định dạng rõ ràng.
Anti‑pattern điển hình: stream JSON không có ký tự phân tách và cố “đoán” nơi kết thúc một object và bắt đầu object khác. Hoặc trộn trong một luồng cả văn bản lẫn JSON. Cách tốt hơn — chọn định dạng đơn giản, dễ hiểu: văn bản theo dòng, hoặc NDJSON (một JSON object mỗi dòng), hoặc phân tách rõ ràng. Khi đó parser ở client vẫn minh bạch.

Lỗi №5: quên timeout và các stream “vĩnh viễn”.
Đôi khi các dev làm SSE endpoint chẳng gửi gì trong 510 phút, rồi ngạc nhiên vì kết nối bị đứt đâu đó trên đường từ người dùng tới server (load balancer, API gateway, proxy doanh nghiệp). Các sự kiện heartbeat định kỳ hoặc bình luận giúp giữ kết nối sống và phát hiện kịp thời việc đứt. Còn HTTP‑stream không nên biến thành phản hồi vô hạn — với đăng ký vĩnh viễn thì đã có SSE.

Lỗi №6: cố làm pub/sub phức tạp qua HTTP‑stream thay vì dùng sự kiện đúng nghĩa.
Đôi lúc nảy sinh cám dỗ: “Làm một stream, qua đó gửi cả tiến độ, partial results và log ngẫu nhiên.” Hệ quả là phía client xuất hiện một bộ ghép kênh phức tạp, phân tích từng chunk và quyết định nó thuộc jobId nào. Trong đa số trường hợp, đơn giản và tin cậy hơn là dùng SSE với các sự kiện kiểu job.progress, job.completed và một kênh riêng cho mỗi job, thay vì phát minh siêu giao thức tự chế trên HTTP‑stream.

Lỗi №7: trói chặt UX vào giả định “luồng không bao giờ rơi”.
Bất kỳ luồng nào rồi cũng sẽ đứt. Nếu widget của bạn khi đó chỉ còn progress bar quay mãi mà không có lựa chọn hành động — UX bị cảm nhận như “bị lỗi”. Ngay cả một thông điệp đơn giản “Có vẻ kết nối đã bị gián đoạn. Hãy thử khởi động lại quy trình chọn quà” kèm nút “Thử lại” cũng tốt hơn im lặng rất nhiều.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION