JavaRush/Courses/ChatGPT Apps/Streaming channels: SSE and HTTP/stream — when and how to...

Streaming channels: SSE and HTTP/stream — when and how to use them

Open

1. Where “streams” show up in the ChatGPT App architecture

Before arguing which is better — SSE or HTTP-stream — it helps to understand where in our stack streams actually exist.

Roughly, there are three layers.

First, the ChatGPT and model layer. The model itself already streams the answer token by token: you see how the answer text is “typed out”. That’s a stream too, but it’s fully controlled by OpenAI and doesn’t relate to your code directly.

Second, the MCP layer. When ChatGPT connects to your MCP server, it typically keeps an SSE connection: the server pushes MCP JSON-RPC messages (responses and notifications) into it, and ChatGPT sends requests back to a separate HTTP endpoint, for example /messages. In MCP terms this is the basic transport.

Third, the Apps SDK and your backend. Your GiftGenius React widget runs in the ChatGPT sandbox and talks to your backend/MCP gateway over HTTP: via a regular fetch, via fetch with a stream (ReadableStream), or via an SSE subscription (EventSource).

It’s important not to lump these layers together. MCP events are the “wire” between ChatGPT and your servers, whereas SSE/HTTP-stream between the widget and your HTTP backend is your own stretch of road.

We can sketch a diagram.

flowchart TD
  subgraph ChatGPT
    UI[ChatGPT UI + model]
    W[GiftGenius Widget]
  end

  subgraph YourInfra[Developer infrastructure]
    GW[MCP Gateway / Backend]
    MCP[MCP Server]
  end

  UI -- "tool-call / responses\n(internal token stream)" --> W

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

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

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

Today we’ll focus on the Widget ↔ Backend arrow and briefly recall that the MCP transport itself is also based on SSE.

It’s precisely on this stretch — Widget ↔ Backend — that we need to choose how to communicate: plain HTTP requests or streams. In the next section we’ll look at why “plain” HTTP quickly becomes insufficient here.

2. Why a plain HTTP request isn’t enough

The standard HTTP model is “request → single response”. The client asks something, the server replies once, the connection closes.

That’s fine for many tasks: get the current job status, save user settings, fetch a ready-made list of gifts that’s already in the database.

But as soon as you perform a long operation, things start to creak.

Imagine GiftGenius that:

  • collects signals from several sources (purchase history, wishlist, social networks),
  • runs them through a couple of LLM calls,
  • builds a personalised ranking from a hundred candidates.

All of that can take dozens of seconds. If you keep a plain HTTP request open for 40 seconds and stay silent, the UX will feel like old browsers: the user stares at a spinner and wonders if the app died or is still “thinking”.

Beyond UX, there are technical issues too:

  • timeouts in ChatGPT, Vercel, and proxies;
  • no way to send progress, partial results, etc.;
  • no way to handle a dropped connection and recover cleanly.

A natural solution follows: switch from one big response to a stream of small chunks that the server can send as they are ready.

Those chunks can be:

  • events (job.progress, job.completed) — that’s SSE;
  • fragments of a single large payload (a report text, NDJSON lines with gifts) — that’s HTTP-stream.

3. SSE (Server‑Sent Events): subscribing to events

Let’s start with SSE, because it’s “closer” to MCP in many ways: MCP itself uses an SSE connection over HTTP to push events from the server to the client.

SSE model in simple terms

SSE is a protocol on top of plain HTTP:

  1. the client opens a GET request to an endpoint that responds with Content-Type: text/event-stream;
  2. the server doesn’t close the connection and periodically writes lines like:
event: job.progress
data: {"jobId":"123","percent":40}

event: job.completed
data: {"jobId":"123","resultCount":12}
  1. the browser side uses EventSource, which:
    • handles reconnections automatically;
    • parses the event: + data: + double newline format;
    • invokes onmessage handlers / addEventListener("job.progress", ...).

The key point: the channel is one‑way. Only the server sends events to the client. The client doesn’t send any data over this connection.

For ChatGPT Apps this model fits well when the widget just wants to “subscribe” to events by jobId and react to progress and completion.

Mini example of an SSE endpoint in Next.js 16

Suppose we have a route handler for a Job’s progress events:

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) {
      // Utility to send an SSE event
      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", // here we set up SSE
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

This is a toy simulation: the percent increases once per second and finally a job.completed arrives. Later you will replace this timer with real worker/queue events, but the overall scheme remains.

Client: subscribing to SSE in the GiftGenius widget

Inside the React widget we can subscribe to this stream when a jobId is available. Reminder: the widget API runs in the ChatGPT sandbox, but EventSource is available there just like in a regular browser.

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 = () => {
      // here you could show "Connection issues, trying to reconnect"
    };

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

  return <div>Gift selection progress: {percent}%</div>;
}

Now you can tie this to an MCP tool. The start_gift_job tool returns a jobId, and in your widget’s ToolOutput you simply render GiftJobProgress.

Auto-reconnect and Last‑Event‑ID

EventSource by standard tries to reconnect automatically if the connection drops. The server can use the standard SSE id: field in events, and the client can use the Last-Event-ID header to catch up on missed events after reconnecting.

For a simple GiftGenius you can initially skip both id: and a separate event identifier and just tolerate a small “progress gap” on reconnect. But in production, especially under high load, you’ll want to:

  • add the standard id: field to each SSE event so that the client can pass Last-Event-ID on reconnect;
  • introduce an application-level event_id in the event payload and rely on it for idempotent handling on the client/backend.

This directly ties into idempotency: even if the same job.progress arrives twice, the handler, seeing a familiar event_id, won’t repeat side effects.

As a result, SSE gives us a convenient subscription to events around a jobId with auto-reconnect and duplicate control via event identifiers. Now let’s look at the second type of streams — when we have a single request but a very large response that we want to deliver in parts.

4. HTTP streaming: gradually responding to a single request

If SSE is “subscribe to independent events”, then HTTP streaming is “one request, one response, but the response is time‑sliced and arrives in chunks”.

This is exactly what you see when using the OpenAI API with stream : true: the server sends JSON chunks (often in SSE format, but the logic is “one request ↔ a stream of partial response”), and the client assembles them into the final text.

In your own APIs you can do the same for:

  • large textual reports (for example, an explanation of the gift choices),
  • long lists of gifts (stream them in parts instead of making the user wait).

A simplest HTTP‑stream endpoint in Next.js

Suppose we need to generate an “explanation” of the selection result where an LLM writes a long text. We want to stream it to the widget as it’s generated.

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("Starting analysis...\n"));

      // Here could be real LLM generation with chunks
      for (const line of ["Collecting preferences...\n", "Calculating budget...\n", "Final recommendations...\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", // Here we indicate that this is HTTP/stream
    },
  });
}

Technically, Next manages chunked encoding itself; you just need to return a ReadableStream.

Reading an HTTP stream in the widget via fetch

On the client (inside the widget) you can read the stream like this:

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); // update UI as chunks arrive
  }
}

And a wrapper component:

import { useState } from "react";

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

  return (
    <div>
      <button onClick={() => fetchReport(setText)}>Generate report</button>
      <pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
    </div>
  );
}

This is a classic pattern: a single POST request to /api/gift-report, and in response — a text stream that you render progressively.

Streaming JSON, not text

Often you’ll want to stream JSON objects instead of strings. The most popular format is NDJSON (Newline‑delimited JSON): each event is one JSON line and ends with a \n character.

Server-side example:

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: `Gift #${i}` };
      controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
      await new Promise((r) => setTimeout(r, 500));
    }
    controller.close();
  },
});

The client reads with a TextDecoder, splits by \n, and parses individual JSON objects.

5. SSE vs HTTP‑stream: what’s the difference and how to choose

By now the picture should feel intuitive, but let’s still capture it in a small table.

Characteristic SSE (Server‑Sent Events) HTTP‑stream (chunked)
Initiator The client makes a GET and subscribes The client makes a request (GET/POST), the server streams the response
Direction Server → client only Server’s response to a specific request
Semantics Subscription to an event stream (pub/sub) Partial response to a single request
Built-in protocol Yes (event:, data:, id:, etc.) No; you define the format yourself (lines, NDJSON, JSON)
Client API EventSource fetch + ReadableStream / response.body
Reconnect support Built-in (EventSource, Last-Event-ID) Must be implemented manually
Typical use cases Progress, statuses, notifications by jobId Streaming text, large JSON responses, LLM output

If we simplify into “rules of thumb” (carefully, no fanaticism):

  • you have a job and lots of events around it → SSE;
  • a single tool call returns a large result that you want to show progressively → HTTP‑stream.

For GiftGenius this means: SSE for a live progress bar and statuses of the selection; HTTP stream for a long textual summary or for gradually loading a long gift list.

6. How this fits with MCP and GiftGenius

Let’s recall our diagram from the beginning: model ↔ MCP ↔ widget ↔ backend. We already looked at streams at the widget ↔ backend layer, and now we’ll step back and clearly separate where MCP is in this story and where it’s “just HTTP”.

MCP defines how ChatGPT (as an MCP client) communicates with your MCP server. There’s a transport in which:

  • ChatGPT opens an SSE connection at /sse and receives MCP messages (responses, notifications, events) through it;
  • ChatGPT sends MCP requests (call_tool, list_tools, etc.) to /messages, usually as a JSON‑RPC POST.

You’ve already gone through this layer when connecting GiftGenius to ChatGPT.

Now, when we add asynchronous jobs and UX streams in the widget, two architectural options appear.

Option one — “pure MCP”: the MCP server itself generates job.progress and job.completed events; ChatGPT receives them via MCP SSE; then the model itself invokes your widget with updated context, and the widget renders progress without talking to the backend directly. This is the most “canonical” MCP‑events path.

Option two — hybrid: the MCP tool start_gift_job creates a job and returns a jobId; the widget gets the jobId and then talks to the backend over HTTP on its own, subscribing to the SSE endpoint /api/gift-jobs/{jobId}/events and, if necessary, requesting an HTTP‑stream report. Nothing special happens on the MCP side in this case.

In the course we take the hybrid route: it fits the App Router/Next flow better and is simpler for local debugging. Later you can move to “pure MCP notifications” once you get the hang of it.

7. Reconnect, timeouts, and other realities of the network

So far it sounded ideal: you open an SSE or a stream, everything flows, events arrive, UX shines. In real life, the network likes to drop connections at unexpected moments, and infrastructure likes to enforce timeouts.

What can go wrong

With SSE and HTTP-stream you’ll eventually encounter:

  • idle timeouts on proxies: “if no data flows over the connection for N seconds — close it”;
  • your backend restarting (deploy, incident);
  • unstable networks on the user’s side (especially on mobile).

That’s normal; the key is to be prepared rather than hope “we’ll get lucky”.

Strategy for SSE

SSE has many advantages precisely in this area:

  • EventSource reconnects on its own with some delay;
  • you have id: and Last-Event-ID to catch up on events.

Minimal set of practices:

  1. From the server, periodically send something like a heartbeat so the connection isn’t considered completely idle. This can be a separate event: ping or just a comment : keep-alive.
  2. On the client, in onerror, show a clear status like “Connection issues, attempting to reconnect…” rather than breaking the entire widget.
  3. On reconnect, if you use id:, have the server send only new events after that ID. For GiftGenius, you can start with no id: at all and simply “rebuild” state from the last received job.progress/job.completed.

Strategy for HTTP‑stream

An HTTP stream is a single request, so if it drops you basically have to start over:

  • if you stream a text report, you can simply tell the user “Failed to fetch the full report, please try again” and start from scratch;
  • if you stream structured data (NDJSON), consider a resume mechanism: for example, pass an offset or cursor in the request to continue from.

To start with, you can keep it simple: if the response stream ended prematurely — show what you have and a “Continue generation” button that sends a new request.

The main thing is not to leave the user in a “waiting forever” state.

8. Applying it to GiftGenius: an end‑to‑end scenario

Now let’s bring together everything we discussed about SSE, HTTP‑stream, and the two architectural options with MCP, in a live GiftGenius scenario — from the user’s request to the final report.

The user in ChatGPT writes: “Pick a gift for a board game fan, budget up to 100 dollars.” The model decides to call GiftGenius. The app/agent makes a tool call start_gift_job on your MCP server. The server:

  • writes the job to the database;
  • sends it to an internal queue (queue/worker details are in the next lecture; for now assume “someone” executes it);
  • returns a jobId synchronously in the tool call response.

The GiftGenius widget receives ToolOutput with a jobId and renders a component:

function GiftGeniusRoot({ jobId }: { jobId: string }) {
  return (
    <div>
      <h2>Looking for the perfect gifts...</h2>
      <GiftJobProgress jobId={jobId} />
      <GiftReport />
    </div>
  );
}

The GiftJobProgress component subscribes to SSE at /api/gift-jobs/{jobId}/events and renders progress. Each job.progress updates the percentage, job.completed sets it to 100% and possibly enables a “Show detailed report” button.

The GiftReport component, when the button is clicked, sends a POST to /api/gift-report (passing the jobId) and progressively displays the report text while the server streams HTTP chunks.

If the SSE connection drops, the widget shows a gentle warning and EventSource attempts to reconnect. If there are issues with the report stream, the user sees the partial report and a “Continue generation” or “Try again” button.

From the ChatGPT and MCP perspective:

  • MCP sees the tool call start_gift_job and, possibly, then notifications about job statuses;
  • The UX around streams is implemented mostly at the HTTP layer between the widget and your backend.

9. Common mistakes when working with SSE and HTTP‑stream

Mistake #1: treating SSE and HTTP‑stream as “the same thing”.
Yes, deep down they both use HTTP and chunked responses, but the semantics are very different. SSE is a subscription to independent events that can arrive anytime, and the client doesn’t know them upfront. HTTP‑stream is one specific response, smeared over time. If you try to implement a subscription to multiple jobId values via a single HTTP stream, you’ll have to invent your own protocol on top of bytes, effectively rebuilding half of SSE.

Mistake #2: ignoring SSE auto‑reconnect and not thinking about idempotency.
Many write a “simple” SSE server: they send data: ... and don’t include the standard id: (for Last-Event-ID) or an application-level event_id in the event body. Then, at the first connection drop and reconnect, event duplicates start multiplying. Without a well‑designed event_id and “I have already seen this event” logic, the client handler risks updating state twice, showing the same job.completed twice, or worse, charging/crediting twice.

Mistake #3: sending every tiny worker update as a separate SSE event.
If you send task progress via SSE every millisecond, you’re more likely to kill the network and the client than delight the user with smooth animation. It’s wiser to aggregate updates and send progress, say, every 200–500 ms or on stage changes. We’ll discuss throttling and backpressure later, but even now you should think about event frequency.

Mistake #4: building complex protocols on top of HTTP‑stream without a clear format.
A typical anti‑pattern: stream JSON without delimiters and try to “guess” where one object ends and the next begins. Or mix text and JSON in one stream. The best path is to pick a simple, clear format: lines of text, or NDJSON (one JSON object per line), or explicit separators. Then the client parser remains sane.

Mistake #5: forgetting about timeouts and “never‑ending” streams.
Sometimes developers make SSE endpoints that don’t send anything for 5–10 minutes, and then wonder why connections drop on the path from user to server (load balancers, API gateways, corporate proxies). Regular heartbeat events or comments keep the connection alive and help detect drops in time. And HTTP streams shouldn’t turn into infinite responses — use SSE for perpetual subscriptions.

Mistake #6: trying to implement complex pub/sub over HTTP‑stream instead of proper events.
It’s sometimes tempting: “We’ll make one stream and send progress, partial results, and random logs over it.” As a result, the client ends up with a complex multiplexer that inspects each chunk and decides which jobId it belongs to. In most cases it’s simpler and more reliable to use SSE with events like job.progress, job.completed and a separate channel per job rather than invent a homegrown mega‑protocol on top of HTTP streaming.

Mistake #7: tightly coupling the UX to the assumption that the stream “never fails”.
Every stream will fail at some point. If your widget then just sits with a forever‑animating progress bar and no fallback actions, the UX feels “broken”. Even a simple message like “It looks like the connection was interrupted. Try restarting gift selection” with a “Retry” button is far better than silence.

1
Task
ChatGPT Apps,  13 level,  1 lecture
Locked
HTTP stream “A report that writes itself”
HTTP stream “A report that writes itself”
1
Task
ChatGPT Apps,  13 level,  1 lecture
Locked
SSE "Task Progress" with keep-alive
SSE "Task Progress" with keep-alive
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet