সরাসরি পর্বে যান
পর্ব ৬১ · ৯৩-এর মধ্যে · মডিউল ৯

SSE আর streaming — polling, long polling আর AI-র উত্তর stream করা

Server-Sent Events and Streaming — Polling, Long Polling and Streaming AI Answers

Taught in Bangla, this lesson compares four ways to deliver updates for an order page: short polling with refetchInterval, long polling done right, Server-Sent Events and WebSockets. It explains the text/event-stream format, EventSource reconnects with Last-Event-ID, SSE in Express 5 and Next.js, connection limits, proxy buffering, compression and backpressure in real runs, and streaming AI answers with a ReadableStream reader in React with AbortController.

এই পর্বে যা শিখবেন

  • Short polling, long polling, SSE আর WebSocket-এর অনুরোধের সংখ্যা আর দেরি মেপে তুলনা করা
  • TanStack Query-র refetchInterval-এর খরচ হিসাব করা আর long polling ঠিকভাবে লেখা
  • text/event-stream-এর গঠন, EventSource, Last-Event-ID দিয়ে reconnect আর heartbeat
  • Express 5 আর Next.js 16.4.4-এর route handler-এ SSE চালানো, buffering আর compression যাচাই করা
  • AI-র উত্তরের token stream পড়া, React-এ দেখানো, AbortController দিয়ে বাতিল করা আর backpressure বোঝা
  • Auth-এর সীমা জেনে কোন পরিস্থিতিতে কোন পথ বাছবেন, তা ঠিক করা

১ · খবর পৌঁছানোর চারটি পথ — বাজারঘরের অর্ডার পাতা

বাজারঘরে একজন ক্রেতা অর্ডার ৪২ দিয়েছেন। তিনি “আমার অর্ডার” পাতাটি খুলে রেখেছেন। অবস্থা বদলায় এভাবে: placed, confirmed, packed, shipped, delivered. প্রশ্ন হলো, গুদামে প্যাক শেষ হলে খবরটি তাঁর পাতায় যাবে কীভাবে? HTTP-তে নিয়ম হলো, browser জিজ্ঞেস করে আর server উত্তর দেয়। Server নিজে থেকে browser-কে ডাকতে পারে না। এই সীমার চারপাশে চারটি কৌশল তৈরি হয়েছে। এদের তত্ত্ব আর বড় পরিসরের নকশা System Design-এর real-time communication পর্বে আছে। এখানে আমরা application developer হিসেবে Node.js, Express 5 আর Next.js-এ এগুলো বানাব আর মাপব।

ধরুন আপনি দর্জির দোকানে পাঞ্জাবি বানাতে দিয়েছেন। Short polling: প্রতি দুই ঘণ্টায় ফোন করে জিজ্ঞেস করেন, “হয়েছে?” বেশির ভাগ উত্তর “না”। Long polling: ফোন করে বলেন, “হলে তখনই বলবেন, আমি লাইনে আছি”; দর্জি কাজ শেষে উত্তর দেন, অথবা অনেকক্ষণ পরে বলেন “এখনো হয়নি, আবার ফোন দিন”। SSE: দোকানের খাতায় আপনার নাম লেখানো, দর্জি প্রতিটি ধাপে আপনাকে বার্তা পাঠান; আপনি শুধু শোনেন। WebSocket: দুজনের মধ্যে খোলা ওয়াকিটকি, দুজনেই যখন খুশি কথা বলেন।

চারটিকে একটি ছবিতে দেখুন। সময় বাঁ থেকে ডানে ৪০ সেকেন্ড। লাল দাগে অর্ডারের অবস্থা বদলায়: ১৩ সেকেন্ডে confirmed, ৩১ সেকেন্ডে packed.

একই ৪০ সেকেন্ড, চার পথে

Short polling, long polling, SSE and WebSocket on one timeline

চার পথের সময়রেখা confirmed packed Short poll ৫টি অনুরোধ, সবুজ ২টিতে নতুন খবর Long poll ৩টি অনুরোধ, প্রতিটি খোলা থাকে বদল পর্যন্ত SSE ১টি সংযোগ · ধূসর দাগ = heartbeat Web Socket ১টি সংযোগ, দুই দিকে বার্তা 0 s 10 20 30 40 s
ছবি ৬১.১ — Short polling-এ খবর আসে পরের অনুরোধের সময়, তাই দেরি হয়। বাকি তিনটিতে খবর প্রায় সাথে সাথে পৌঁছায়, পার্থক্য সংযোগের ধরনে।

ছবিটি ধারণা। এবার আসল মাপ। একটি সত্যিকারের ল্যাবে Express 5.3.0 server (Node 24.15.0) একই অর্ডার ৪২-এর জন্য চারটি endpoint দিয়েছে। চারটি client একসাথে ২০ সেকেন্ড পাতাটি দেখেছে। অবস্থা বদলেছে ৩.০, ৯.৫ আর ১৬.২ সেকেন্ডে। Short polling প্রতি ২ সেকেন্ডে জিজ্ঞেস করেছে, long polling-এর timeout ৮ সেকেন্ড। WebSocket-এর জন্য ws package ব্যবহার হয়েছে; এর বিস্তারিত পর্ব ৬০-এ (WebSocket — Socket.IO, room আর real-time chat). সবকিছু চলেছে শুধু 127.0.0.1-এ।

$ node measure.js        # সত্যিকারের run, একবারের local মাপ
window 20 s, changes at 3.0 s, 9.5 s, 16.2 s
poll  requests/connections = 10  lag ms = 1020, 553, 1886
long  requests/connections =  4  lag ms = 2, 1, 1
sse   requests/connections =  1  lag ms = 3, 2, 2
ws    requests/connections =  1  lag ms = 3, 2, 2
পথ২০ সেকেন্ডে অনুরোধখবর পৌঁছাতে দেরিদিক
Short polling১০ (৭টি “নতুন কিছু নেই”)০.৫৫ থেকে ১.৮৯ সেকেন্ডclient জিজ্ঞেস করে
Long polling৪ (শেষটি তখনো খোলা)১–২ msclient জিজ্ঞেস করে, server অপেক্ষা করায়
SSE১টি সংযোগ২–৩ msশুধু server → client
WebSocket১টি সংযোগ২–৩ msদুই দিকে

এটি একটি মেশিনে একবারের মাপ, সাধারণ benchmark নয়। Local-এ network-এর দেরি প্রায় শূন্য, তাই long polling, SSE আর WebSocket সমান দেখায়। আসল internet-এ প্রতিটিতে যোগ হবে এক দিকের যাত্রার সময়, আর long polling-এ প্রতিটি উত্তরের পরে নতুন অনুরোধ পাঠানোর সময়। কিন্তু মূল শিক্ষা বদলায় না: polling-এর দেরি নির্ভর করে বিরতির ওপর, আর তার বেশির ভাগ অনুরোধ খালি হাতে ফেরে।

২ · Short polling — TanStack Query-র refetchInterval আর তার খরচ

Short polling সবচেয়ে সরল। আলাদা server লাগে না, proxy-র ঝামেলা নেই, সাধারণ GET আর cache-এর নিয়ম খাটে। পর্ব ১৯-এ আপনি TanStack Query দিয়ে server state এনেছেন। সেখানে polling মানে একটি option:

import { useQuery } from '@tanstack/react-query';

function OrderStatus({ id }: { id: number }) {
  const { data } = useQuery({
    queryKey: ['order', id],
    queryFn: () => fetch(`/api/orders/${id}`).then((r) => r.json()),
    // delivered হলে আর জিজ্ঞেস নয়; না হলে প্রতি ৫ সেকেন্ডে
    refetchInterval: (query) =>
      query.state.data?.status === 'delivered' ? false : 5000,
    // refetchIntervalInBackground: false  ← ডিফল্ট: লুকানো tab-এ থামে
  });
  return <p>অবস্থা: {data?.status ?? '…'}</p>;
}

এখানে দুটি জিনিস লক্ষ করুন। TanStack Query v5-এর docs অনুযায়ী refetchInterval একটি সংখ্যা, false, অথবা একটি ফাংশন নিতে পারে, যা query দেখে বিরতি ঠিক করে। তাই অর্ডার পৌঁছে গেলে polling নিজেই থামে। আর refetchIntervalInBackground-এর ডিফল্ট false: tab লুকানো থাকলে polling বন্ধ। এই দুটি ছোট সিদ্ধান্ত server-এর বোঝা অনেক কমায়। (উদাহরণটি এই পর্বের ল্যাবে চালানো হয়নি; option-গুলো লেখার সময়ের TanStack Query 5.105-এর docs থেকে মেলানো।)

খরচের হিসাব

Polling-এর খরচ সহজ সূত্রে বাঁধা যায়। ধরুন Nটি খোলা পাতা, প্রতিটি T সেকেন্ড পরপর জিজ্ঞেস করে।

  • Server-এ অনুরোধের হার: N / T অনুরোধ প্রতি সেকেন্ডে।
  • গড় দেরি: বদল যেকোনো মুহূর্তে হতে পারে, তাই গড়ে T / 2, সবচেয়ে খারাপ ক্ষেত্রে প্রায় T (সাথে network-এর সময়)।
  • খালি অনুরোধের ভাগ: এক ঘণ্টায় kটি বদল হলে 3600 / Tটি অনুরোধের মধ্যে বড়জোর kটি কাজের।
হিসাব করে দেখুন

সন্ধ্যার ভিড়ে বাজারঘরে ২,০০০টি অর্ডার পাতা খোলা, T = 5 সেকেন্ড। হার = 2000 / 5 = ৪০০ অনুরোধ/সেকেন্ড, শুধু “নতুন কিছু আছে?” জানতে। এক ঘণ্টায় প্রতিটি পাতা 3600 / 5 = ৭২০টি অনুরোধ পাঠায়; অর্ডারে ৪টি বদল হলে ৭১৬টি খালি। গড় দেরি ২.৫ সেকেন্ড। T ২ সেকেন্ডে নামালে দেরি ১ সেকেন্ড, কিন্তু হার ১,০০০/সেকেন্ড। দ্রুততা আর খরচ এখানে সরাসরি উল্টো দিকে টানে।

তবু polling খারাপ নয়। যেখানে কয়েক সেকেন্ডের দেরি চলে (পেমেন্টের অবস্থা, রিপোর্ট তৈরি হলো কি না), ব্যবহারকারী কম, বা পরিবেশে দীর্ঘ সংযোগ রাখা যায় না, সেখানে polling-ই সবচেয়ে কম ঝুঁকির পথ। Short polling-এর উত্তরে ETag দিলে অপরিবর্তিত উত্তর 304 হয়ে ছোট হয়, কিন্তু অনুরোধের সংখ্যা কমে না।

৩ · Long polling ঠিকভাবে — timeout, cursor আর আবার জিজ্ঞেস করা

Long polling-এ client একই রকম GET পাঠায়, কিন্তু server নতুন কিছু না থাকলে উত্তর দেয় না, অনুরোধটি ধরে রাখে। বদল হলেই উত্তর যায়, আর client সাথে সাথে পরের অনুরোধ পাঠায়। ঠিকভাবে লিখতে চারটি নিয়ম মানতে হয়:

  1. Cursor: client বলে দেয় সে কোন version পর্যন্ত জানে (?since=3). দুই অনুরোধের ফাঁকে বদল হলে server সাথে সাথে উত্তর দেয়, কিছু হারায় না।
  2. Timeout: server নিজেই একটি সীমা পরে খালি উত্তর (204) দেয়। সীমাটি পথের সব reverse proxy-এর timeout-এর চেয়ে কম হতে হবে। nginx-এর proxy_read_timeout-এর ডিফল্ট ৬০ সেকেন্ড, তাই ২৫–৩০ সেকেন্ড প্রচলিত পছন্দ।
  3. পরিষ্কার করা: client চলে গেলে (req.on('close')) timer আর listener সরাতে হবে, না হলে memory জমে।
  4. ভুলের পরে ধীরে ফেরা: network ভুলে সাথে সাথে আবার চেষ্টা নয়; ক্রমশ বাড়ানো অপেক্ষা (backoff) আর একটু এলোমেলো সময় (jitter), যাতে server ফিরলে সবাই একসাথে না আসে।
// Express 5: long polling, ল্যাবের server.js-এর ধারণা পরিষ্কার নামে
app.get('/api/orders/:id/wait', async (req, res) => {
  const since = Number(req.query.since ?? 0);
  const now = await orders.current(req.params.id, req.user.id);
  if (!now) return res.sendStatus(404);
  if (now.version > since) return res.json(now);       // আগেই নতুন কিছু আছে
  const off = bus.once(`order:${now.id}`, (o) => { clearTimeout(timer); res.json(o); });
  const timer = setTimeout(() => { off(); res.status(204).end(); }, 25_000);
  req.on('close', () => { clearTimeout(timer); off(); });  // client চলে গেছে
});
// Browser: long polling-এর loop, backoff আর jitter সহ
async function watch(id, since, render) {
  for (let fails = 0; ; ) {
    try {
      const r = await fetch(`/api/orders/${id}/wait?since=${since}`);
      if (r.status === 200) { const o = await r.json(); since = o.version; render(o); }
      fails = 0;                                  // 204 হলে শুধু আবার জিজ্ঞেস
    } catch {
      fails++;
      const wait = Math.min(30_000, 1000 * 2 ** fails) * (0.5 + Math.random() / 2);
      await new Promise((ok) => setTimeout(ok, wait));
    }
  }
}

দুটি ব্লকই উদাহরণ; ল্যাবে চলেছে একই নিয়মের সরল রূপ, যার মাপ ওপরের টেবিলে (৪টি অনুরোধ, ১–২ ms দেরি)। orders.current আর bus এখানে কাল্পনিক helper. একাধিক server process থাকলে bus হতে হবে সবার ভাগ করা কিছু, যেমন পর্ব ৪৪-এর Redis-এর pub/sub; এর নকশা System Design-এর pub/sub পর্বে।

এবার short আর long polling পাশাপাশি, একটি ছোট সময়রেখায়: short polling প্রতি ১০ সেকেন্ডে, long polling-এর timeout ২০ সেকেন্ড, বদল ১৩ আর ৩১ সেকেন্ডে।

1t=0   দুই client প্রথম অনুরোধ পাঠায়2t=10  short: অনুরোধ #23t=13  বদল: placed → confirmed4t=20  short: অনুরোধ #35t=30  short: অনুরোধ #46t=31  বদল: confirmed → packed7t=40  short: অনুরোধ #5
কে কী জানে, কত অনুরোধ
আসল অবস্থা
short জানে
short অনুরোধ
long জানে
long অনুরোধ
  1. শুরু। পাতা খুলেছে, অবস্থা placed. Short client অনুরোধ #1 পাঠিয়ে সাথে সাথে উত্তর পায়। Long client since=1 দিয়ে অনুরোধ #1 পাঠায়, server তা ধরে রাখে, সর্বোচ্চ ২০ সেকেন্ড।
  2. ১০ সেকেন্ড। Short client নিয়মমতো অনুরোধ #2 পাঠায়, উত্তর আবার placed. একটি পুরো যাত্রা, কোনো নতুন খবর ছাড়া। Long client-এর #1 এখনো খোলা।
  3. ১৩ সেকেন্ডে অবস্থা confirmed. Server ধরে রাখা long #1-এর উত্তর তখনই দেয়, দেরি প্রায় শূন্য। Long client সাথে সাথে since=2 দিয়ে #2 পাঠায়। Short client এখনো placed দেখাচ্ছে।
  4. ২০ সেকেন্ড। Short #3 অবশেষে confirmed পায়। বদল হয়েছিল ১৩-তে, তাই এই ক্রেতা খবরটি ৭ সেকেন্ড দেরিতে দেখলেন।
  5. ৩০ সেকেন্ড। Short #4, আবার কিছু নতুন নেই। Long #2 খোলা আছে ১৩ থেকে; এর timeout হতো ৩৩-এ।
  6. ৩১ সেকেন্ডে packed. Long #2-এর উত্তর timeout-এর আগেই যায়, দেরি শূন্য, আর #3 খোলে। Short client এখনো confirmed দেখাচ্ছে।
  7. ৪০ সেকেন্ড। Short #5 packed পায়, দেরি ৯ সেকেন্ড। হিসাব: short-এ ৫টি অনুরোধ, কাজের মাত্র ২টি; long-এ ৩টি, প্রতিটি কাজের, তবে প্রতিটি অনেকক্ষণ server-এ একটি সংযোগ ধরে রেখেছে।

এক ঘণ্টার হিসাব হাতে না করে একটি নকল ঘড়িতে চালান। কোনো সত্যিকারের সময় বা এলোমেলো সংখ্যা নেই, তাই আউটপুট প্রতিবার এক।

// Fake clock in seconds: one order page open for 1 hour, 4 status changes.
const changes = [127, 913, 2411, 3304];
const HOUR = 3600;

function shortPolling(every) {
  let requests = 0, lagSum = 0, seen = 0;
  for (let t = 0; t < HOUR; t += every) {
    requests++;
    while (seen < changes.length && changes[seen] <= t) { lagSum += t - changes[seen]; seen++; }
  }
  return { requests, avgLag: lagSum / seen };
}

function longPolling(timeout) {
  let requests = 0, t = 0, next = 0;
  while (t < HOUR) {
    requests++;                                   // one held request
    const change = changes[next];
    if (change !== undefined && change <= t + timeout) { t = change; next++; }  // answered at once, lag ~0
    else t += timeout;                            // empty timeout, ask again
  }
  return { requests, avgLag: 0 };
}

for (const s of [2, 5, 30]) {
  const r = shortPolling(s);
  console.log(`short poll every ${String(s).padStart(2)} s: ${String(r.requests).padStart(4)} requests, avg lag ${r.avgLag.toFixed(1)} s`);
}
const lp = longPolling(30);
console.log(`long poll, 30 s timeout: ${String(lp.requests).padStart(4)} requests, avg lag ~0 s`);
console.log(`SSE or WebSocket:         1 connection (+ heartbeats), avg lag ~0 s`);
const pages = 2000, r5 = shortPolling(5).requests;
console.log(`2000 open pages, 5 s polling: ${(pages * r5 / HOUR).toFixed(0)} requests/s at the server`);
console.log(`2000 open pages, long poll:   ${(pages * lp.requests / HOUR).toFixed(1)} requests/s, but 2000 held connections`);

আউটপুটের প্রথম তিন লাইন: short poll every 2 s: 1800 requests, avg lag 0.8 s, every 5 s: 720 requests, avg lag 2.5 s, every 30 s: 120 requests, avg lag 21.3 s. বিরতি ছোট করলে দেরি কমে, অনুরোধ বাড়ে। ৫ সেকেন্ডের হিসাব হাতে মেলান: বদল 127, 913, 2411, 3304 সেকেন্ডে, পরের অনুরোধ 130, 915, 2415, 3305-এ; দেরি 3 + 2 + 4 + 1 = 10, গড় ২.৫. পরের লাইন long poll, 30 s timeout: 122 requests: ১২০টি খালি timeout-এর চক্র, আর ৪টি বদলের কারণে চক্র আগে ভাঙায় সামান্য বেশি। শেষ দুই লাইনে ২,০০০ পাতায় 400 requests/s বনাম 67.8 requests/s, but 2000 held connections. Long polling অনুরোধ কমায়, কিন্তু খরচটি সরে যায় খোলা সংযোগে। মডেলে network-এর সময় ধরা হয়নি, আর long polling-এর দেরি শূন্য ধরে নেওয়া; এটি শেখার মডেল, মাপ নয়।

৪ · SSE-এর তারের ভাষা — text/event-stream

Server-Sent Events (SSE) আসলে একটি সাধারণ HTTP GET, যার উত্তর শেষ হয় না। Server header-এ বলে Content-Type: text/event-stream, তারপর সময়ে সময়ে কয়েক লাইন লেখা পাঠায়। নিয়মটি WHATWG-এর HTML মানে লেখা, আর এত সরল যে curl দিয়ে চোখে পড়া যায়। ল্যাবের Express server থেকে সত্যিকারের run:

$ curl -N -i http://127.0.0.1:47969/api/orders/42/events    # সত্যিকারের run, Express 5.3.0
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache
X-Accel-Buffering: no
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

retry: 1000

id: 1
event: status
data: {"order":42,"status":"placed"}

: ping

id: 2
event: status
data: {"order":42,"status":"confirmed"}

: ping

প্রতিটি event কয়েকটি নাম: মান লাইন, আর একটি ফাঁকা লাইনে event শেষ। চারটি নাম অর্থ বহন করে:

Fieldকাজখেয়াল রাখুন
dataবার্তার মূল লেখাএকাধিক data লাইন হলে মাঝে newline দিয়ে জোড়া লাগে
eventevent-এর নামনা দিলে নাম message; নাম দিলে browser-এ addEventListener('status', …) লাগবে
idশেষ দেখা event-এর চিহ্নBrowser মনে রাখে, পরের সংযোগে Last-Event-ID হিসেবে ফেরত পাঠায়
retryReconnect-এর আগে অপেক্ষা, millisecond-এশুধু পূর্ণসংখ্যা; অন্য কিছু হলে উপেক্ষা
: …CommentBrowser উপেক্ষা করে; সংযোগ জাগিয়ে রাখার heartbeat হিসেবে কাজে লাগে

লক্ষ করুন, chunked transfer encoding-এ উত্তরের মোট দৈর্ঘ্য আগে বলা নেই, তাই HTTP/1.1-এ উত্তর খোলা রেখে টুকরো পাঠানো যায়। এর বাইরে SSE নতুন কোনো protocol নয়। নিচের ব্লকে node:http দিয়ে একটি ছোট SSE server আর একটি client, যে পুরো স্রোত পড়ে নিজে event-এ ভাঙে।

const http = require("node:http");
// SSE server: one stream for order 42
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  res.write("retry: 2000\n\n");                       // reconnect wait: 2 s
  res.write(": heartbeat, the browser ignores this\n\n");
  res.write("id: 7\nevent: status\ndata: {\"status\":\"packed\"}\n\n");
  res.write("data: line one\ndata: line two\n\n");    // no event name = "message"
  setTimeout(() => res.end("id: 8\nevent: status\ndata: {\"status\":\"shipped\"}\n\n"), 50);
});

server.listen(0, "127.0.0.1", async () => {
  const url = `http://127.0.0.1:${server.address().port}/orders/42/events`;
  const res = await fetch(url);
  console.log(res.status, res.headers.get("content-type"));
  const text = await res.text();                      // whole stream, then parse
  for (const block of text.split("\n\n")) {
    if (!block) continue;
    const ev = { event: "message", data: [] };
    for (const line of block.split("\n")) {
      if (line.startsWith(":")) { ev.comment = true; continue; }
      const i = line.indexOf(":");
      const field = line.slice(0, i), value = line.slice(i + 1).replace(/^ /, "");
      if (field === "data") ev.data.push(value); else ev[field] = value;
    }
    if (ev.comment) console.log("comment  (ignored)");
    else if (ev.retry) console.log("retry    ", ev.retry, "ms");
    else console.log(`${ev.event.padEnd(8)} id=${ev.id ?? "-"} data=${JSON.stringify(ev.data.join("\n"))}`);
  }
  server.close();
});

প্রথম লাইন 200 text/event-stream. তারপর retry 2000 ms, comment (ignored), status id=7 data="{\"status\":\"packed\"}", message id=- data="line one\nline two" আর status id=8 …shipped…. দুটি data লাইন একটি বার্তায় জোড়া লেগেছে, মাঝে \n. নাম না থাকায় চতুর্থটি message. একটি সূক্ষ্ম কথা: আসল EventSource-এ id মনে থাকে, তাই “line one” বার্তার lastEventIdও 7 হতো; আমাদের সরল client প্রতিটি event আলাদা দেখছে। এই client পুরো উত্তর শেষ হওয়ার অপেক্ষা করেছে (res.text())। সত্যিকারের স্রোত শেষ হয় না, তাই টুকরো টুকরো পড়তে হয়। পরের ব্লকে সেটিই।

টুকরো যেখানে খুশি ভাঙে — একটি শক্ত parser

Network কখনো event-এর সীমা মেনে টুকরো পাঠায় না। একটি TCP টুকরো \r-এর পরে আর \n-এর আগে শেষ হতে পারে। বাংলা অক্ষর UTF-8-এ ৩ byte, টুকরো তার মাঝখানেও ভাঙতে পারে। নিচের parser দুটোই সামলায়: TextDecoder-কে stream: true দিয়ে অর্ধেক অক্ষর ধরে রাখে, আর শেষের অসম্পূর্ণ লাইন পরের টুকরোর জন্য রেখে দেয়।

// An SSE parser that survives chunks split anywhere (even inside a Bangla letter)
function createParser(onEvent) {
  const decoder = new TextDecoder();            // stream: true keeps half a letter
  let buf = "", ev = { data: [] }, lastId = "";
  return function feed(bytes) {
    buf += decoder.decode(bytes, { stream: true });
    const cr = buf.endsWith("\r");              // "\r" may be half of "\r\n"
    const lines = (cr ? buf.slice(0, -1) : buf).split(/\r\n|\r|\n/);
    buf = lines.pop() + (cr ? "\r" : "");       // last piece may be incomplete
    for (const line of lines) {
      if (line === "") {                        // blank line = dispatch
        if (ev.data.length) onEvent({ type: ev.event || "message", id: lastId, data: ev.data.join("\n") });
        ev = { data: [] }; continue;
      }
      if (line[0] === ":") continue;            // comment / heartbeat
      const i = line.indexOf(":");
      const f = i < 0 ? line : line.slice(0, i);
      const v = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
      if (f === "data") ev.data.push(v);
      else if (f === "event") ev.event = v;
      else if (f === "id") lastId = v;
    }
  };
}

const wire = new TextEncoder().encode(
  ": ping\r\n\r\nid: 41\r\nevent: status\r\ndata: প্যাক হয়েছে\r\n\r\ndata: a\ndata: b\n\n");
const cuts = [3, 17, 47, 50, 70];   // 17 splits "\r|\n"; 47 and 50 split a letter
const feed = createParser((e) => console.log("  EVENT", JSON.stringify(e)));
let start = 0;
for (const end of [...cuts, wire.length]) {
  console.log(`chunk ${start}-${end}`);
  feed(wire.slice(start, end)); start = end;
}

আউটপুটে ছয়টি টুকরো: chunk 0-3, 3-17, 17-47, 47-50, 50-70, 70-94. প্রথম event বেরিয়েছে একদম শেষ টুকরোর পরে: EVENT {"type":"status","id":"41","data":"প্যাক হয়েছে"}, অক্ষর অক্ষত। কারণ ফাঁকা লাইনটি (\r\n\r\n) এসেছে শেষ টুকরোয়। তারপর EVENT {"type":"message","id":"41","data":"a\nb"}: নাম নেই বলে message, আর id আগের 41-ই রয়ে গেছে, ঠিক browser-এর মতো। byte ৪৭ আর ৫০ “য” আর “া”-এর মাঝখানে পড়েছে; stream: true না দিলে সেখানে ভাঙা অক্ষর (�) দেখা যেত। byte ১৭ \r আর \n-এর মাঝে; সতর্কতা না থাকলে একটি নকল ফাঁকা লাইন তৈরি হতো।

৫ · Browser-এ EventSource — নিজে থেকে reconnect আর Last-Event-ID

Browser-এ নিজে parser লিখতে হয় না। EventSource object সংযোগ খোলে, event ভাঙে, আর সংযোগ কাটলে নিজেই আবার চেষ্টা করে। ল্যাবের পাতার কোড:

const es = new EventSource('/api/orders/42/events');
es.onopen = () => log('open, readyState =', es.readyState);
es.onerror = () => log('error, readyState =', es.readyState);
es.addEventListener('status', (e) => log('status', e.data, 'lastEventId =', e.lastEventId));
// পাতা ছেড়ে গেলে বা আর দরকার না হলে: es.close();

readyState-এর তিনটি মান: 0 সংযোগ হচ্ছে (CONNECTING), 1 খোলা (OPEN), 2 বন্ধ (CLOSED). Server ভুল status (যেমন 401) বা ভুল Content-Type দিলে browser আর চেষ্টা করে না, অবস্থা হয় 2. শুধু network-এর বিচ্ছেদে আবার চেষ্টা হয়।

এবার আসল পরীক্ষা। Microsoft Edge-কে Playwright দিয়ে চালিয়ে ল্যাবের পাতাটি খোলা হয়েছে। দুটি বদলের পরে server process-টিকে তার নিজের process ID দিয়ে বন্ধ করা হয়েছে। Server বন্ধ থাকার সময়ে database-এর জায়গায় রাখা একটি JSON ফাইলে আরেকটি বদল (v4 shipped) লেখা হয়েছে, যেন অন্য কোনো কর্মী তা করেছে। তারপর নতুন server চালু, আর আরেকটি বদল (delivered). পাতার log আর দুই server-এর log:

# সত্যিকারের run: Edge + Express 5.3.0, একবারের local মাপ
[    4ms] open, readyState = 1
[    5ms] status {"order":42,"status":"placed"} lastEventId = 1
[  609ms] status {"order":42,"status":"confirmed"} lastEventId = 2
[ 1011ms] status {"order":42,"status":"packed"} lastEventId = 3
--- server killed (by PID) ---
[ 1620ms] error, readyState = 0
[ 2631ms] error, readyState = 0
--- (server down) DB records v4 shipped ---
[ 3633ms] error, readyState = 0
--- server restarted ---
[ 4652ms] open, readyState = 1
[ 4652ms] status {"order":42,"status":"shipped"} lastEventId = 4
[ 6152ms] status {"order":42,"status":"delivered"} lastEventId = 5

== server A
SSE connect, Last-Event-ID = (none) cookie = sid=example-session
CHANGE -> confirmed v2
CHANGE -> packed v3
== server B
SSE connect, Last-Event-ID = 3 cookie = sid=example-session
CHANGE -> delivered v5
  1. EdgeEventSourceপাতা খোলা
  2. Server Aid 1–3চলছে
  3. Event storev4 shippedserver ছাড়াই লেখা
  4. Server Bid 4, 5নতুন process
পাতার log০–১০১১ ms
open, readyState = 1
status placed     lastEventId = 1
status confirmed  lastEventId = 2
status packed     lastEventId = 3
Server A বন্ধ১৬২০ ms
process ID দিয়ে বন্ধ করা হলো
error, readyState = 0   (CONNECTING)
browser মনে রেখেছে: lastEventId = 3
Retryretry: 1000
[1620ms] error, readyState = 0
[2631ms] error, readyState = 0
প্রায় ১ সেকেন্ড পরপর চেষ্টা, কারণ
server শুরুতে পাঠিয়েছিল retry: 1000
Server নেই, কাজ থামেনিevent store
[{"id":1,"status":"placed"},
 {"id":2,"status":"confirmed"},
 {"id":3,"status":"packed"},
 {"id":4,"status":"shipped"}]
[3633ms] error, readyState = 0
Server B দেখে৪৬৫২ ms
GET /api/orders/42/events
Last-Event-ID: 3
Cookie: sid=example-session
→ history থেকে id > 3 পাঠানো হয়: id 4 shipped
পাতার logশেষ
[4652ms] open, readyState = 1
[4652ms] status shipped    lastEventId = 4
[6152ms] status delivered  lastEventId = 5
কোনো event হারায়নি, কোনোটি দুবার আসেনি
  1. স্বাভাবিক স্রোত। পাতা খুলতেই open, তারপর id 1, 2, 3. প্রতিটি event-এর id browser নিজের কাছে রাখে; এখন lastEventId = 3.
  2. Server হঠাৎ বন্ধ। ১৬২০ ms-এ error, আর readyState 0, অর্থাৎ CLOSED নয়, CONNECTING. Browser হাল ছাড়েনি। আপনার কোডে onerror-এ es.close() ডাকলে এই সুবিধা হারাবেন।
  3. নিয়মিত চেষ্টা। ২৬৩১ ms-এ আবার error, প্রায় ১ সেকেন্ড পরে। কারণ server প্রথমেই retry: 1000 পাঠিয়েছিল। না পাঠালে browser নিজের ডিফল্ট ব্যবহার করে, যা browser ভেদে আলাদা।
  4. Server না থাকলেও ঘটনা ঘটে। গুদামের কর্মী অর্ডার পাঠিয়ে দিয়েছেন, তাই event store-এ id 4 shipped জমা হলো। কোনো খোলা সংযোগ নেই, তাই এই খবর এখন কেউ পায়নি। ৩৬৩৩ ms-এ আরেকবার ব্যর্থ চেষ্টা।
  5. নতুন server, পুরোনো স্মৃতি। ৪৬৫২ ms-এ সংযোগ খোলে, আর অনুরোধে Last-Event-ID: 3. Cookie-ও নিজে থেকে এসেছে। Server B বোঝে, এই client 3 পর্যন্ত জানে, তাই store থেকে 3-এর পরের সব event পাঠায়।
  6. ফাঁক পূরণ। একই মুহূর্তে shipped (id 4) আসে, যা server বন্ধ থাকার সময়ে ঘটেছিল। দেড় সেকেন্ড পরে delivered (id 5) আসে স্বাভাবিক পথে। কোনো event হারায়নি, কোনোটি দুবার আসেনি। শর্ত একটিই: server-এর কাছে id ধরে পড়া যায় এমন event-এর ইতিহাস থাকতে হবে।

এই নকশার মূল দাবি: Last-Event-ID শুধু তখনই কাজের, যখন id-গুলো ক্রমিক বা তুলনা করা যায় এমন, আর ইতিহাস কোথাও টেকসইভাবে রাখা। শুধু memory-তে রাখলে server restart-এ ইতিহাস হারায়। ল্যাবে JSON ফাইল ছিল database-এর বদলি; বাস্তবে এটি database-এর একটি events টেবিল, Redis Streams, বা পর্ব ৪৩-এর outbox টেবিল হতে পারে। ইতিহাস কতদিন রাখবেন, সেটিও নীতি: খুব পুরোনো Last-Event-ID এলে পুরো বর্তমান অবস্থা একবার পাঠিয়ে দেওয়াই নিরাপদ।

Heartbeat কেন লাগে

অর্ডারের অবস্থা হয়তো ২০ মিনিট বদলাবে না। তখন সংযোগে কোনো byte চলবে না। পথে থাকা proxy, load balancer বা মোবাইল অপারেটরের NAT অনেক সময় নীরব সংযোগকে মৃত ধরে কেটে দেয়। আরেক দিকে server জানতেও পারে না যে client আসলে চলে গেছে। সমাধান হলো প্রতি ১৫–৩০ সেকেন্ডে একটি comment লাইন: : ping. Browser এটি উপেক্ষা করে, MDN-ও সংযোগ জাগিয়ে রাখতে এই পথের কথা বলে। বিরতি অবশ্যই পথের সবচেয়ে ছোট idle timeout-এর চেয়ে কম হবে।

৬ · Express 5 আর Next.js 16.4.4-এ SSE

Express 5

ল্যাবের server-এর SSE অংশ, পরিষ্কার নামে সাজানো। এখানে অনুমতি যাচাই, ইতিহাস থেকে ফাঁক পূরণ, heartbeat আর বিদায়ের পরে পরিষ্কার করা, চারটিই আছে।

app.get('/api/orders/:id/events', requireSession, async (req, res) => {
  const order = await orders.findForUser(req.params.id, req.user.id);
  if (!order) return res.sendStatus(404);              // অন্যের অর্ডার নয়
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders();                                  // header এখনই পাঠান
  res.write('retry: 3000\n\n');
  const last = Number(req.get('Last-Event-ID') ?? 0);
  for (const e of await events.since(order.id, last)) send(res, e);  // ফাঁক পূরণ
  const off = bus.on(`order:${order.id}`, (e) => send(res, e));
  const hb = setInterval(() => res.write(': ping\n\n'), 20_000);
  req.on('close', () => { clearInterval(hb); off(); });
});

function send(res, e) {
  res.write(`id: ${e.id}\nevent: status\ndata: ${JSON.stringify(e.data)}\n\n`);
}

এটি উদাহরণ; requireSession, orders, events, bus কাল্পনিক helper, যেগুলো পর্ব ৪৫ আর পর্ব ৪৮-এর ধাঁচে বানানো যায়। ল্যাবে চলেছে এর সরল রূপ, যার curl আউটপুট ৪ নম্বর অংশে। একটি জিনিস মনে রাখুন: পর্ব ৩১-এর graceful shutdown-এ server খোলা সংযোগ শেষ হওয়ার অপেক্ষা করে। SSE সংযোগ নিজে থেকে শেষ হয় না, তাই shutdown-এর সময় এগুলো নিজে বন্ধ করতে হবে; client তো Last-Event-ID নিয়ে অন্য instance-এ ফিরবেই।

Next.js 16.4.4-এর route handler

পর্ব ৫৭-এ দেখেছেন, route handler একটি Web Response ফেরত দেয়। Response-এর body হতে পারে একটি ReadableStream, যাতে আমরা সময়ে সময়ে byte ঢোকাই (enqueue). ল্যাবের আসল ফাইল:

const enc = new TextEncoder();
const steps = ['confirmed', 'packed', 'shipped'];

export async function GET(request: Request, ctx: RouteContext<'/api/orders/[id]/events'>) {
  const { id } = await ctx.params;
  const last = Number(request.headers.get('last-event-id') ?? 1);
  let timer: ReturnType<typeof setInterval> | undefined;
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(enc.encode('retry: 3000\n\n'));
      let v = last;
      timer = setInterval(() => {
        v += 1;
        if (v > steps.length + 1) { clearInterval(timer); controller.close(); return; }
        const data = JSON.stringify({ order: Number(id), status: steps[v - 2] });
        controller.enqueue(enc.encode(`id: ${v}\nevent: status\ndata: ${data}\n\n`));
      }, 400);
    },
    cancel() { clearInterval(timer); console.log('client went away'); },
  });
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
      'X-Accel-Buffering': 'no',
    },
  });
}

এটি শেখার জন্য: আসল অ্যাপে setInterval-এর বদলে event store আর pub/sub থাকবে। next build-এর route table আর next start-এর পরে প্রতিটি টুকরো কখন পৌঁছায়, তার সত্যিকারের মাপ (Next.js 16.4.4, React 19.3.1, Node 24.15.0):

$ next build                                  # সত্যিকারের run
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/chat
├ ƒ /api/orders/[id]/events
└ ƒ /api/plain

$ node client.mjs /api/orders/42/events      # next start, 127.0.0.1
/api/orders/42/events  -> 200 content-type=text/event-stream; charset=utf-8 content-encoding=- transfer-encoding=chunked
  +  25 ms  13 bytes  "retry: 3000\n\n"
  + 437 ms  61 bytes  "id: 2\nevent: status\ndata: {\"order\":42,\"status\":\"confirmed\"}\n
  + 846 ms  58 bytes  "id: 3\nevent: status\ndata: {\"order\":42,\"status\":\"packed\"}\n\n"
  +1258 ms  59 bytes  "id: 4\nevent: status\ndata: {\"order\":42,\"status\":\"shipped\"}\n\n
  end at +1669 ms

$ curl -s -N --max-time 0.6 http://127.0.0.1:47968/api/orders/42/events
retry: 3000

id: 2
event: status
data: {"order":42,"status":"confirmed"}
# server-এর log-এ:
client went away

তিনটি ফল স্পষ্ট। প্রথমত, route-টি ƒ (dynamic), প্রতি অনুরোধে চলে। দ্বিতীয়ত, buffer হয়নি: প্রতিটি event প্রায় ৪০০ ms পরপর নিজের টুকরোয় এসেছে, পুরো উত্তরের শেষে একসাথে নয়। তৃতীয়ত, curl ০.৬ সেকেন্ডে চলে যেতেই stream-এর cancel() ডাকা হয়েছে, log-এ client went away. এই hook না থাকলে timer চলতেই থাকত। আরেকটি পরীক্ষা: Accept-Encoding: gzip পাঠালে এই একই run-এ HTML পাতা (/) gzip হয়ে এসেছে, কিন্তু দুটি SSE route-এর কোনোটিই compress হয়নি, এমনকি no-transform ছাড়া route-টিও না। এটি এই version-এ একবারের পর্যবেক্ষণ; ভবিষ্যতের version বা আপনার সামনের proxy আলাদা আচরণ করতে পারে, তাই নিজের deploy-এ মেপে নেবেন।

Serverless-এ সাবধান। অনেক serverless বা edge platform-এ একটি function কতক্ষণ চলতে পারে, তার সীমা আছে, আর প্রতি সেকেন্ড খোলা থাকার খরচও হতে পারে। ঘণ্টাব্যাপী SSE সেখানে হয় কাটা পড়বে, নয় দামি হবে। AI-র উত্তরের মতো কয়েক সেকেন্ডের stream সাধারণত ঠিক আছে; দীর্ঘ স্রোতের জন্য আলাদা দীর্ঘজীবী Node process ভালো। নির্দিষ্ট সীমা platform ভেদে আলাদা আর বদলায়, তাই নিজের platform-এর docs দেখুন। কোথায় চালাবেন, তা পর্ব ৮২-এ (কোথায় চালাবেন — VPS, shared hosting, PaaS আর serverless).

৭ · পথের বাধা — সংযোগের সীমা, proxy, compression আর backpressure

SSE সরল, কিন্তু browser আর server-এর মাঝের প্রতিটি স্তর এটিকে ভাঙতে পারে। চারটি বাধা সত্যিকারের run-এ দেখুন।

ক. HTTP/1.1-এ ছয়টি সংযোগ

HTTP/1.1-এ একটি সংযোগে একসাথে একটিই উত্তর চলে। Browser তাই এক host-এ কয়েকটি সমান্তরাল সংযোগ খোলে, Chromium আর Firefox-এ ৬টি। প্রতিটি SSE একটি সংযোগ চিরদিনের জন্য দখল করে। MDN বলছে, এই সীমা browser আর domain মিলিয়ে, সব tab একসাথে, আর Chrome ও Firefox-এ এটি “Won't fix” হিসেবে চিহ্নিত। HTTP/2-তে একটি TCP সংযোগে বহু stream চলে (multiplexing); সংখ্যাটি দুই পক্ষ ঠিক করে, RFC 9113 অন্তত ১০০ রাখার পরামর্শ দেয়। একটি পাতা ৮টি EventSource খুলে তারপর একটি সাধারণ fetch করেছে, প্রথমে HTTP/1.1-এ, তারপর TLS-সহ HTTP/2-তে (node:http2, নিজের বানানো পরীক্ষার certificate):

# সত্যিকারের run: Microsoft Edge, ২.৫ সেকেন্ড দেখা
== HTTP/1.1
[    5ms] stream 1 open
[    6ms] stream 2 open
  …  (3, 4, 5)
[    6ms] stream 6 open
                       ← stream 7, 8 আর fetch: কোনো উত্তর নেই
== HTTP/2 over TLS
[    7ms] stream 1 open
  …  (2 থেকে 7)
[   10ms] stream 8 open
[  323ms] fetch /api/ping -> pong (2.0)

HTTP/1.1-এ সপ্তম আর অষ্টম stream খোলেইনি, আর fetch-ও আটকে গেছে, কারণ ছয়টি সংযোগই SSE-র দখলে। অর্থাৎ ভুলটি শুধু “কিছু notification আসছে না” নয়, পুরো সাইটের API থেমে যায়। HTTP/2-তে আটটিই খুলেছে, fetch-ও উত্তর পেয়েছে। বাস্তবে production-এ HTTP/2 সাধারণত সামনের proxy বা CDN দেয়; Node server নিজে HTTP/1.1 বললেও browser থেকে proxy পর্যন্ত পথটি HTTP/2 হতে পারে। তবু নকশার নিয়ম: এক পাতায় একটিই SSE সংযোগ, তার ভেতরে event নাম দিয়ে নানা খবর। একাধিক tab-এ সংযোগ ভাগ করতে BroadcastChannel বা SharedWorker ব্যবহারের কৌশলও প্রচলিত।

খ. Proxy-র buffering

nginx-এর মতো reverse proxy ডিফল্টে পেছনের server-এর উত্তর জমিয়ে (buffer করে) তারপর client-কে দেয়। সাধারণ পাতার জন্য এটি ভালো, SSE-র জন্য মারাত্মক: event-গুলো জমে থাকে, বড় দলা হয়ে দেরিতে পৌঁছায়। nginx docs অনুযায়ী proxy_buffering on ডিফল্ট, আর উত্তরে X-Accel-Buffering: no header দিলে সেই উত্তরের জন্য buffering বন্ধ হয়। তাই আমাদের দুই server-ই এই header পাঠায়। সাথে proxy_read_timeout-এর ডিফল্ট ৬০ সেকেন্ড, তাই heartbeat এর চেয়ে ঘন হওয়া চাই। এই পর্বের ল্যাবে nginx চালানো হয়নি; এই অংশ nginx-এর অফিসিয়াল docs থেকে, আর অন্য proxy বা CDN-এর নিয়ম আলাদা হতে পারে।

গ. Compression স্রোত আটকে দেয়

gzip ভালো সংকোচন করতে কিছু byte জমিয়ে তারপর লেখে। Express-এর জনপ্রিয় compression middleware (1.8.2) একই Express server-এ লাগিয়ে তিনটি অবস্থা মাপা হয়েছে। Heartbeat ছিল ৩০০ ms পরপর, একটি বদল ৫০০ ms-এ।

$ node compress.js        # সত্যিকারের run, একবারের local মাপ
== compression(), no flush: headers after 28 ms, content-encoding = gzip
no body bytes in 2 s
== compression() + res.flush(): headers after 5 ms, content-encoding = gzip
7 ms: 71 bytes
525 ms: 69 bytes
== no compression: headers after 3 ms, content-encoding = null
4 ms: 71 bytes
314 ms: 8 bytes
514 ms: 61 bytes
617 ms: 8 bytes
… 8 chunks in 2 s
== compression(), Cache-Control: no-transform, no flush: content-encoding = null
27 ms: 71 bytes
339 ms: 8 bytes
541 ms: 61 bytes
… 8 chunks in 2 s

প্রথম অবস্থায় দুই সেকেন্ডে একটি byte-ও আসেনি: সব gzip-এর পেটে। দ্বিতীয় অবস্থায় শুধু event লেখার পরে res.flush() ডাকা হয়েছিল, heartbeat-এর পরে নয়। তাই ৩০০ ms-এর heartbeat আটকে ছিল, আর ৫২৫ ms-এ event-এর সাথে একসাথে বেরিয়েছে: ৬১ + ৮ = ৬৯ byte. শিক্ষা: compression রাখলে প্রতিটি লেখার পরে flush. সহজ পথ হলো SSE-তে compression না করা। শেষ run দেখায়, compression package Cache-Control: no-transform পেলে নিজেই সংকোচন বাদ দেয়; এটি package-এর নিজের নিয়ম, অন্য middleware বা proxy-তে আলাদা হতে পারে।

ঘ. Backpressure — ধীর পাঠক

দ্রুত লেখক আর ধীর পাঠকের সমস্যা পর্ব ২৪-এর stream-এ দেখেছেন। SSE-তে পাঠক হলো দুর্বল মোবাইল network-এর একজন ক্রেতা। res.write() false ফেরত দিলে বোঝায়, ভেতরের buffer ভরে গেছে, 'drain' পর্যন্ত থামুন। একটি client যে পড়া বন্ধ করে দিয়েছে, তার দিকে দেড় সেকেন্ড ধরে প্রায় ১ KB-র event লেখা হয়েছে:

$ node backpressure.js    # সত্যিকারের run, একবারের local মাপ
naive       events=5650  held in Node memory=2.7 MB  drain-waits=0
drain-aware events=1750  held in Node memory=0.0 MB  drain-waits=103

সাধারণ লেখক থামেনি; ২.৭ MB জমে গেছে Node-এর memory-তে, আর সময় বাড়লে এটি বাড়তেই থাকত। এমন কয়েকশো client মানে server-এর memory শেষ। Backpressure মানা লেখক ১০৩ বার থেমেছে, Node-এ প্রায় কিছুই জমেনি; বাকি byte অপারেটিং সিস্টেমের socket buffer-এ। বাস্তব নিয়ম: buffer একটি সীমা পেরোলে সেই client-কে বিচ্ছিন্ন করুন; সে Last-Event-ID নিয়ে পরে ফিরবে। ReadableStream-এ একই ধারণা আসে pull() আর desiredSize দিয়ে: পাঠক চাইলে তবেই উৎপাদন। বড় পরিসরের overload নিয়ন্ত্রণ System Design-এর overload আর backpressure পর্বে।

আরও গভীরে: SSE-র নিয়ম WHATWG HTML Living Standard-এর “Server-sent events” অধ্যায়ে। সেখানে parser-কে line-by-line state machine হিসেবে লেখা: \r\n, \r বা \n তিনটিই লাইন শেষ, শুরুর UTF-8 BOM বাদ, আর স্রোত ফাঁকা লাইন ছাড়া শেষ হলে অসম্পূর্ণ event ফেলে দিতে হয়। id-তে NULL অক্ষর থাকলে তা উপেক্ষা করা হয়। HTTP/2-র stream multiplexing-এর বিস্তার Computer Networks-এর HTTP/2 আর HTTP/3 পর্বে।

৮ · AI-র উত্তর stream করা — token, React আর AbortController

বাজারঘরের সাহায্য পাতায় একটি AI সহকারী যোগ হলো: “আমার অর্ডার কবে আসবে?” একটি language model পুরো উত্তর একবারে বানায় না, একটি একটি token (শব্দ বা শব্দের অংশ) করে বানায়। পুরো উত্তরের জন্য ১০ সেকেন্ড অপেক্ষা করালে মনে হয় পাতা আটকে গেছে। তাই প্রায় সব AI chat প্রথম token আসতেই লেখা দেখাতে শুরু করে। অনেক AI API-র streaming উত্তর ঠিক এই পর্বের text/event-stream ছাঁচেই আসে, তবে নাম আর JSON-এর আকার provider ভেদে আলাদা।

এই পর্বে কোনো আসল AI API ডাকা হয়নি। নিচের সব উদাহরণে token আসে একটি স্থির তালিকা থেকে, স্থানীয়ভাবে, সময় দিয়ে ভাগ করে। Event-এর নাম (start, delta, done) আমাদের নিজেদের বানানো; কোনো নির্দিষ্ট provider-এর ছাঁচ নয়। আসল provider-এর ছাঁচ তার নিজের docs থেকে নেবেন।

দুটি কারণে এখানে EventSource চলে না। এটি শুধু GET পাঠায়, অথচ প্রশ্নটি body-তে POST করতে হয়। আর এটি নিজের header বসাতে দেয় না। তাই client fetch দিয়ে POST করে, আর response.body, একটি ReadableStream, reader দিয়ে টুকরো টুকরো পড়ে। Next.js-এর ল্যাবে একটি /api/chat route ছিল, যা ১৫০ ms পরপর একটি নকল token পাঠায়, আর client বাতিল করলে cancel()-এ উৎপাদন থামায়। Node-এর fetch দিয়ে (browser-এর সেই একই API) সত্যিকারের run, প্রথমে পুরো উত্তর, তারপর ৫২০ ms-এ abort():

$ node chat-client.mjs          # সত্যিকারের run, Next.js 16.4.4, নকল token
+  50 ms  [start] {"id":"msg_1"}
+ 192 ms  ডেলিভারি
+ 343 ms  ডেলিভারি সাধারণত
+ 494 ms  ডেলিভারি সাধারণত ঢাকার
  …
+1251 ms  ডেলিভারি সাধারণত ঢাকার ভেতরে ২–৩ দিনে হয়।
+1403 ms  [done] {"tokens":8}

$ node chat-client.mjs 520      # ৫২০ ms-এ AbortController.abort()
+  31 ms  [start] {"id":"msg_1"}
+ 186 ms  ডেলিভারি
+ 338 ms  ডেলিভারি সাধারণত
+ 490 ms  ডেলিভারি সাধারণত ঢাকার
+523 ms  AbortError: stopped, kept "ডেলিভারি সাধারণত ঢাকার"
# next start-এর log:
chat: client aborted after 3 tokens, generation stopped

শেষ লাইনটিই সবচেয়ে জরুরি। ব্যবহারকারী “থামান” চাপলে শুধু পর্দায় লেখা থামানো যথেষ্ট নয়। Server-কে জানতে হবে, আর server-কেও তার পেছনের AI provider-এর অনুরোধ বাতিল করতে হবে; নইলে কেউ পড়ছে না এমন token-এর জন্য সময় আর (অনেক provider-এ) টাকা খরচ হতেই থাকবে। Route handler-এ request.signal upstream-এর fetch-এ দিয়ে দিলে, বা upstream-এর body সরাসরি নিজের Response-এ দিলে, বাতিলের খবর পুরো পথে ছড়ায়। (এটি নকশার পরামর্শ; আসল provider-এর সাথে এই পর্বে পরীক্ষা হয়নি।)

এবার React-এ। নিচের live ব্লকে fakeAnswer() একটি স্থানীয় ReadableStream বানায়, কোনো network নেই। কম্পোনেন্ট reader দিয়ে পড়ে, প্রতিটি token-এ setText ডাকে, আর “থামান” বোতাম AbortController দিয়ে স্রোত ভাঙে।

<div id="root"></div>
<script src="../course-kit/vendor/react-lab.js"></script>
<script type="text/jsx">
// No real AI is called: fakeAnswer() makes a local stream of tokens.
const TOKENS = ["অর্ডার", " ৪২", " প্যাক", " হয়েছে,", " আজ", " রাতে", " রওনা", " দেবে।"];
function fakeAnswer(signal) {
  let i = 0, t;
  return new ReadableStream({
    start(c) { t = setInterval(() => i < TOKENS.length ? c.enqueue(TOKENS[i++]) : (clearInterval(t), c.close()), 60);
      signal.addEventListener("abort", () => { clearInterval(t); c.error(new DOMException("stopped", "AbortError")); }); },
  });
}
function Chat() {
  const [text, setText] = useState(""); const [busy, setBusy] = useState(false); const ctl = useRef(null);
  async function ask() {
    ctl.current = new AbortController(); setText(""); setBusy(true); let all = "";
    const reader = fakeAnswer(ctl.current.signal).getReader();
    try { for (;;) { const { value, done } = await reader.read(); if (done) break; all += value; setText(all); } console.log("done:", all); }
    catch (e) { console.log(e.name + ", kept:", all); }
    setBusy(false);
  }
  useEffect(() => { ask(); }, []);
  return <div><p style={{ minHeight: "2em" }}>{text}{busy && "▌"}</p>
    <button onClick={ask} disabled={busy}>আবার জিজ্ঞেস করুন</button>{" "}
    <button onClick={() => ctl.current.abort()} disabled={!busy}>থামান</button></div>;
}
createRoot(document.getElementById('root')).render(<Chat />);
</script>

পাতা খুললে উত্তর নিজে থেকে ৬০ ms পরপর একটি করে token নিয়ে বাড়ে, শেষে console-এ done: অর্ডার ৪২ প্যাক হয়েছে, আজ রাতে রওনা দেবে। Edge-এ Playwright দিয়ে চালিয়ে “আবার জিজ্ঞেস করুন” চাপার ২০০ ms পরে “থামান” চাপা হয়েছিল; পর্দায় রইল অর্ডার ৪২ প্যাক, আর console-এ AbortError, kept: অর্ডার ৪২ প্যাক. তিনটি খুঁটিনাটি লক্ষ করুন। প্রতিটি token-এ পুরো লেখা (all) দিয়ে setText, তাই পুরোনো state-এর ওপর নির্ভর নেই। বাতিল হলে যা এসেছে তা মুছে না দিয়ে রেখে দেওয়া, ঠিক যেমন বড় chat অ্যাপগুলো করে। আর “▌” cursor দেখায় যে লেখা এখনো আসছে। আসল অ্যাপে কম্পোনেন্ট unmount হলে useEffect-এর cleanup-এ abort() ডাকবেন (পর্ব ১৩)।

  1. Browserfetch POSTsignal সহ
  2. route.tsenqueueনকল token
  3. readerread()parse
  4. ReactsetTextrender
অনুরোধ০ ms
const ac = new AbortController();
fetch('/api/chat', { method: 'POST',
  body: '{"q":"delivery?"}', signal: ac.signal })
তারে প্রথম byte+৩১ ms
event: start
data: {"id":"msg_1"}

(একটি ফাঁকা লাইন = event শেষ)
প্রথম token+১৮৬ ms
event: delta
data: {"text":"ডেলিভারি"}

reader.read() → { value, done: false }
parser → delta "ডেলিভারি"
পর্দা+৪৯০ ms
text = "ডেলিভারি সাধারণত ঢাকার"
setText(text) → re-render
পর্দায়: ডেলিভারি সাধারণত ঢাকার▌
থামান+৫২৩ ms
ac.abort()
client: AbortError, kept "ডেলিভারি সাধারণত ঢাকার"
server: chat: client aborted after 3 tokens,
        generation stopped
  1. প্রশ্ন যায় POST-এ। EventSource নয়, কারণ body আর header লাগবে। সাথে একটি AbortController-এর signal, যাতে পরে থামানো যায়।
  2. Header আর প্রথম event. ৩১ ms-এ start event আসে। Server পুরো উত্তরের অপেক্ষা করেনি; Response ফেরত দিয়েছে খালি stream দিয়ে, তারপর তাতে enqueue করছে।
  3. প্রথম token. ১৮৬ ms-এ delta event. reader.read() কিছু byte দেয়; parser ফাঁকা লাইন পর্যন্ত জমিয়ে event বের করে। Token কখনো দুই টুকরোয় ভাঙতে পারে, তাই ৪ নম্বর অংশের parser-এর মতো জমানো লাগে।
  4. আংশিক লেখা পর্দায়। তিনটি token জোড়া লেগে “ডেলিভারি সাধারণত ঢাকার”। প্রতিটি token-এ একবার setText আর re-render. খুব দ্রুত token এলে কয়েকটি একসাথে জমিয়ে প্রতি frame-এ একবার render করা ভালো।
  5. মাঝপথে থামা। ৫২৩ ms-এ abort(). Client-এ AbortError, যা এসেছিল তা থেকে গেছে। Server-এ stream-এর cancel() চলেছে: ৩টি token-এর পরে উৎপাদন থেমেছে, বাকি ৫টি আর তৈরি হয়নি।

Streaming-এর আরও কয়েকটি বাস্তব প্রশ্ন আছে। আংশিক Markdown (অর্ধেক ** বা অর্ধেক code block) দেখানোর সময় renderer যেন ভেঙে না যায়। AI-র লেখা কখনো সরাসরি HTML হিসেবে বসাবেন না; সাধারণ লেখা হিসেবে দেখান বা sanitize করুন (পর্ব ৫০)। Screen reader-এর জন্য প্রতিটি token ঘোষণা না করে উত্তর শেষে একবার ঘোষণা করাই ভালো। আর Next.js-এর পাতার HTML streaming (Suspense দিয়ে) আলাদা জিনিস, সেটি পর্ব ৫৩-এ; এখানে আমরা ডেটার স্রোত নিয়ে কথা বলছি।

৯ · SSE-তে auth, আর কখন কোন পথ

Cookie চলে, নিজের header চলে না

EventSource-এর constructor শুধু URL আর একটি option নেয়: withCredentials. Authorization: Bearer … header বসানোর কোনো উপায় নেই। ওপরের reconnect run-এ দেখেছেন, একই origin-এ cookie নিজে থেকেই গেছে (cookie = sid=example-session), পুনঃসংযোগেও। তাই session cookie দিয়ে login করা অ্যাপে (পর্ব ৪৫) SSE সহজ। অন্য পথগুলো:

  • অন্য origin-এ API: new EventSource(url, { withCredentials: true }), আর server-এ Access-Control-Allow-Credentials: true সহ নির্দিষ্ট origin (* নয়)। CORS-এর নিয়ম পর্ব ৫০-এ।
  • URL-এ token দেবেন না। ?token=… server log, proxy log আর browser history-তে থেকে যায়। একান্তই লাগলে খুব কম আয়ুর, একবার ব্যবহারযোগ্য “ticket” দিন, যা সংযোগের সময়ই বাতিল হয়।
  • Header দরকার হলে fetch-ভিত্তিক SSE: AI অংশের মতো fetch আর reader দিয়ে নিজে parse করুন। তবে তখন reconnect আর Last-Event-ID নিজেকে লিখতে হবে।
  • দীর্ঘ সংযোগে মেয়াদ: সংযোগ খোলার সময় session বৈধ ছিল, কিন্তু ঘণ্টা পরে logout বা অনুমতি বাতিল হতে পারে। মাঝে মাঝে session আবার যাচাই করুন, বা logout-এ সেই ব্যবহারকারীর stream বন্ধ করুন। প্রতিটি event পাঠানোর আগে অনুমতির নিয়ম মানুন, শুধু সংযোগের শুরুতে নয়।

Token-ভিত্তিক login-এর তত্ত্ব Cybersecurity-র sessions আর tokens পর্বে।

কোনটি কখন

পরিস্থিতিসাধারণত ভালো পছন্দকেন
কয়েক সেকেন্ড দেরি চলে, ব্যবহারকারী কম (রিপোর্ট তৈরি হলো কি না)Short pollingসবচেয়ে সরল, cache আর সাধারণ hosting-এ চলে
দীর্ঘ সংযোগ রাখা যায় না এমন পরিবেশ, তবু দ্রুত খবর চাইLong pollingসাধারণ HTTP, প্রায় সব proxy পার হয়
শুধু server → client: অর্ডারের অবস্থা, notification, dashboard, AI-র উত্তরSSEসাধারণ HTTP, নিজে reconnect, Last-Event-ID, cookie চলে
দুই দিকে ঘন কথা: chat, যৌথ সম্পাদনা, খেলাWebSocket (পর্ব ৬০)একই সংযোগে দুই দিক, কম overhead
একটি প্রশ্ন, লম্বা উত্তর (AI chat)fetch POST + stream readerBody আর header লাগে, এক উত্তরেই শেষ
পাতা বন্ধ থাকলেও খবর (মোবাইলে)Push notification (পর্ব ৬৩)খোলা সংযোগ ছাড়াই অপারেটিং সিস্টেম পৌঁছে দেয়

এখানে সম্প্রদায়ে মতভেদ আছে। কেউ বলেন, সব কিছুতে WebSocket নিলে একটি প্রযুক্তিই শিখতে হয়। অন্যরা বলেন, বেশির ভাগ অ্যাপের real-time আসলে এক দিকের, আর SSE সাধারণ HTTP হওয়ায় auth, log, load balancer সবই পুরোনো নিয়মে চলে। দুই পক্ষেরই যুক্তি আছে; বাজারঘরের অর্ডার পাতার জন্য SSE যথেষ্ট, আর বিক্রেতা-ক্রেতার chat-এর জন্য WebSocket. লাখো সংযোগের capacity আর reconnect storm-এর নকশা System Design-এ।

পরীক্ষা ও ইন্টারভিউতে:

  • SSE আর WebSocket-এর মূল পার্থক্য? SSE সাধারণ HTTP-তে এক দিকের (server → client) লেখার স্রোত, নিজে reconnect করে; WebSocket আলাদা protocol-এ দুই দিকের বার্তা।
  • সংযোগ কাটলে SSE কীভাবে কিছু না হারিয়ে ফেরে? Browser শেষ id Last-Event-ID header-এ পাঠায়; server ইতিহাস থেকে বাকিগুলো দেয়।
  • ৫ সেকেন্ডের polling-এ ৩,০০০ পাতা হলে অনুরোধের হার? 3000 / 5 = ৬০০/সেকেন্ড; গড় দেরি ২.৫ সেকেন্ড।
  • Production-এ SSE event দেরিতে দলা হয়ে আসে, কেন? সাধারণত proxy-র buffering বা compression; X-Accel-Buffering: no, compression বাদ বা প্রতিবার flush.
  • EventSource-এ Bearer token পাঠানো যায়? না, নিজের header বসানো যায় না; cookie, withCredentials বা fetch-ভিত্তিক stream ব্যবহার করুন।

১০ · AI-এর যুগে SSE আর streaming

AI-কে real-time order status বানাতে বললে সে সাধারণত কাজ চলার মতো কোড দেয়। সমস্যা লুকিয়ে থাকে এমন জায়গায়, যা local-এ ধরা পড়ে না: proxy, reconnect, memory, auth. নিচের ভুলগুলো খেয়াল রাখুন।

AI-এর ভুলকীভাবে ধরবেন
new EventSource(url, { headers: { Authorization } })এমন option নেই; MDN-এ constructor দেখুন। Cookie বা fetch-stream
id নেই, বা id আছে কিন্তু server Last-Event-ID পড়ে নাServer বন্ধ করে আবার চালান; মাঝের event এসেছে কি না দেখুন
req.on('close')-এ interval বা listener সরানো নেই১০০ বার সংযোগ খুলে-বন্ধ করে listener-এর সংখ্যা log করুন
onerror-এ es.close() আর নিজের reconnectBrowser-এর নিজের reconnect নষ্ট হয়; দরকার কি না ভাবুন
Global compression(), SSE route বাদ নেইcurl -N দিয়ে দেখুন, event সময়মতো আসে কি না
AI stream-এ “থামান” শুধু UI-তে, server বা provider চলতেই থাকেServer log-এ বাতিলের লাইন আসে কি না দেখুন
Token URL-এ, বা provider-এর API key browser-এর কোডেDiff-এ ?token= আর key খুঁজুন; key শুধু server-এ
নেই এমন package বা পুরোনো API (যেমন Pages Router-এর res.write Next.js App Router-এ)npm view, আর নিজের version-এর official docs

AI-কে version, পরিবেশ আর পরীক্ষার শর্ত বলে দিন। আসল API key, cookie বা ক্রেতার তথ্য prompt-এ দেবেন না।

আমি Express 5.3 আর Node 24 ব্যবহার করছি। বাজারঘর অ্যাপে GET /api/orders/:id/events নামে একটি SSE endpoint লিখে দাও। শর্ত: ১. Session cookie দিয়ে login যাচাই হবে (requireSession middleware আছে, নিজে বানাবে না)। অন্যের অর্ডার হলে 404. ২. প্রতিটি event-এ id দাও। Last-Event-ID header পড়ে events.since(orderId, lastId) থেকে মিস হওয়া event আগে পাঠাও। ৩. retry: 3000, প্রতি 20 সেকেন্ডে ": ping" heartbeat, আর req.on('close')-এ সব timer ও listener পরিষ্কার করো। ৪. Content-Type text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no দাও। Compression middleware এই route-এ যেন না লাগে। ৫. res.write() false দিলে কী করবে, তা লেখো; buffer 1 MB পেরোলে সংযোগ বন্ধ করো। ৬. কোনো নতুন npm package যোগ করবে না। ৭. শেষে তিনটি পরীক্ষার ধাপ লেখো: curl -N দিয়ে event দেখা, server বন্ধ করে আবার চালিয়ে Last-Event-ID দেখা, আর ধীর client. ফলাফল নিজে চালাওনি, তাই "পাস করেছে" বলবে না।

AI-এর লেখা streaming কোড মেলানোর চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১SSE প্রায় দুই দশক পুরোনো, তবু বহু বছর WebSocket-এর ছায়ায় ছিল। AI chat-এর যুগে হঠাৎ এটি সবখানে কেন?

Server থেকে browser-এ স্রোত পাঠানোর ধারণা WHATWG-এর Web Applications কাজের অংশ হিসেবে ২০০০-এর দশকের মাঝামাঝি প্রস্তাবিত হয়, Ian Hickson-এর সম্পাদনায়। Opera প্রায় ২০০৬ সালে এর একটি পরীক্ষামূলক রূপ চালু করে বলে জানা যায়; পরে নিয়মটি আজকের EventSource আকার নেয় আর HTML মানের অংশ হয়। WebSocket মান হিসেবে আসে ২০১১ সালে, RFC 6455-এ।

তাহলে SSE পিছিয়ে ছিল কেন? কয়েকটি বাস্তব কারণ। তখনকার একটি বড় browser, Internet Explorer, কখনো EventSource সমর্থন করেনি, তাই polyfill লাগত। HTTP/1.1-এর ছয় সংযোগের সীমা একাধিক tab-এ সমস্যা করত। পুরোনো proxy আর antivirus স্রোত buffer করে ফেলত। আর WebSocket দুই দিকেই কথা বলে, তাই “এক প্রযুক্তিতে সব” ভাবনায় সেটিই জিতল। Socket.IO-র মতো library-ও বিকল্প পথ হিসেবে long polling রাখত, SSE নয়।

দুটি বদল হাওয়া ঘুরিয়েছে। প্রথমত, HTTP/2 (RFC 7540, ২০১৫; এখন RFC 9113) এক সংযোগে বহু stream চালায়, ফলে সংযোগের সীমা প্রায় অর্থহীন হলো, আর TLS-এর কারণে মাঝের proxy স্রোত কম ছোঁয়। দ্বিতীয়ত, AI chat. একটি প্রশ্ন, তারপর কয়েক সেকেন্ড ধরে token-এর স্রোত; এটি নিখুঁতভাবে এক দিকের। এর জন্য আলাদা protocol, আলাদা load balancer নিয়ম আর আলাদা auth-এর দরকার নেই; সাধারণ HTTP POST-এর উত্তরকেই স্রোত বানালে চলে। তাই বহু AI API streaming-এর জন্য text/event-stream-এর ছাঁচ বেছে নিয়েছে, যদিও browser-এ তা EventSource দিয়ে নয়, fetch দিয়ে পড়া হয়।

এখান থেকে একটি সাধারণ শিক্ষা পাওয়া যায়। প্রযুক্তির জনপ্রিয়তা শুধু তার নকশার মানের ওপর নির্ভর করে না; আশেপাশের পরিবেশ (browser, protocol, proxy) আর সময়ের সবচেয়ে বড় ব্যবহার কোনটি, তার ওপরও নির্ভর করে। একই নকশা পরিবেশ বদলালে আবার প্রাসঙ্গিক হয়ে ওঠে।

মূল উপলব্ধি: SSE হারিয়ে যায়নি, তার বাধাগুলো সরেছে। এক দিকের স্রোতের জন্য সবচেয়ে সরল হাতিয়ার প্রায়ই সঠিক হাতিয়ার, আর “সবকিছু WebSocket” একটি অভ্যাস, নিয়ম নয়।

প্র ০২Long polling-কে অনেকে “পুরোনো কৌশল” বলেন। তবু আজও অনেক real-time library-তে এটি থাকে কেন?

২০০০-এর দশকের মাঝামাঝি browser-এ server push-এর কোনো মান ছিল না। ডেভেলপাররা নানা কৌশল বানালেন: লুকানো iframe-এ শেষ না হওয়া উত্তর, script tag-এর ধারাবাহিক লোড, আর long polling. Alex Russell ২০০৬ সালে এই কৌশলগুলোর সমষ্টির নাম দেন “Comet”, Ajax নামের সাথে মিলিয়ে (দুটিই ঘর পরিষ্কারের পণ্যের নাম)। Web-এর প্রথম দিকের chat আর live notification-এর বড় অংশ এই কৌশলেই চলত।

Long polling-এর বড় শক্তি হলো, এটি দেখতে একদম সাধারণ HTTP অনুরোধ। কোনো Upgrade header নেই, কোনো অদ্ভুত content type নেই, উত্তর একসময় শেষ হয়। ফলে কড়া corporate proxy, পুরোনো firewall, কিছু মোবাইল network, যারা WebSocket আটকে দেয় বা দীর্ঘ স্রোত buffer করে, তারাও long polling পার হতে দেয়। এজন্য Socket.IO-র মতো library ঐতিহাসিকভাবে প্রথমে HTTP long polling দিয়ে সংযোগ শুরু করত, তারপর সম্ভব হলে WebSocket-এ উঠত; এর বিস্তারিত পর্ব ৬০-এ দেখবেন।

দুর্বলতাও স্পষ্ট। প্রতিটি বার্তার পরে নতুন অনুরোধ, তাই ঘন বার্তায় header-এর খরচ আর দেরি বাড়ে। দুটি অনুরোধের মাঝের ফাঁকে বার্তা হারানো রোধে cursor লাগে। Server-এ প্রতিটি অপেক্ষমাণ অনুরোধ একটি খোলা সংযোগ, তাই thread-per-request server-এ (প্রতিটি অনুরোধে একটি thread) এটি দ্রুত সীমায় পৌঁছায়। Node.js-এর event loop-এ অপেক্ষমাণ সংযোগ শুধু একটু memory, তাই সেখানে long polling সস্তা; Dan Kegel-এর ১৯৯৯ সালের “C10K” প্রবন্ধ এই পার্থক্যকেই আলোচনায় এনেছিল।

আজ long polling মূলত তিনটি জায়গায় বাঁচে: fallback হিসেবে, এমন পরিবেশে যেখানে দীর্ঘ স্রোত রাখা যায় না, আর কিছু API-তে যেখানে client নিজে ঠিক করতে চায় কখন পরের খবর নেবে (যেমন কিছু message queue-র “long poll” receive).

মূল উপলব্ধি: পুরোনো কৌশল মানেই বাতিল কৌশল নয়। Long polling-এর মূল্য তার সাধারণত্বে: যে পথ সব বাধা পার হয়, সেটি নিরাপত্তা জাল হিসেবে টিকে থাকে।

প্র ০৩Last-Event-ID থাকলে কি বলা যায় “প্রতিটি event ঠিক একবার পৌঁছায়”? না হলে কী নিশ্চয়তা পাওয়া যায়?

না, আর এই পার্থক্য বোঝা খুব জরুরি। Distributed system-এ বার্তা পৌঁছানোর তিনটি নিশ্চয়তার কথা বলা হয়: বড়জোর একবার (at-most-once), অন্তত একবার (at-least-once), আর ঠিক একবার (exactly-once). Network-এর ওপর দিয়ে শুধু পাঠানো আর পাওয়া দিয়ে সত্যিকারের “ঠিক একবার” পাওয়া যায় না; যা পাওয়া যায় তা হলো “অন্তত একবার পাঠানো” আর “গ্রাহকের দিকে একই বার্তা দুবার এলেও ফল একবারের মতো”।

Last-Event-ID কী দেয়? সংযোগ কাটলে client বলে, “আমি এই id পর্যন্ত পেয়েছি”। কিন্তু browser id মনে রাখে event পুরোপুরি পাওয়ার পরে। ধরুন server id 7 পাঠাল, byte-গুলো পথে, আর ঠিক তখন সংযোগ কাটল। Client-এর কাছে শেষ id 6, তাই server 7 আবার পাঠাবে; এটিই ঠিক আচরণ। উল্টো দিকে, client event পেয়েছে কিন্তু UI আপডেটের আগে পাতা crash করেছে; তখন পরের পাতা নতুন EventSource খুলবে Last-Event-ID ছাড়া, তাই পুরো অবস্থা আবার লাগবে। অর্থাৎ নিশ্চয়তা হলো: ইতিহাস থাকলে ফাঁক পূরণ হয়, কিন্তু একই event দুবার আসা সম্ভব।

তাই client-কে idempotent হতে হবে: অর্ডারের অবস্থা “shipped” দুবার এলে UI একবারই বদলায়, কারণ এটি অবস্থা বসানো, যোগ করা নয়। “নতুন বার্তা +১” ধরনের গণনা হলে id দিয়ে দুবার আসা বাদ দিতে হবে। পর্ব ৩৩-এর idempotency key-র যুক্তি এখানে উল্টো দিক থেকে খাটে।

Server-এর দিকেও একটি ফাঁদ আছে। Database-এ অর্ডার বদলানো আর event store-এ event লেখা দুটি আলাদা কাজ হলে, একটি সফল আর অন্যটি ব্যর্থ হতে পারে। তখন অর্ডার shipped, কিন্তু কোনো event নেই, আর Last-Event-ID কিছুই উদ্ধার করতে পারবে না। এর সমাধান পর্ব ৪৩-এর outbox: একই transaction-এ অবস্থা আর event, তারপর আলাদা কর্মী event প্রকাশ করে।

মূল উপলব্ধি: SSE-র reconnect দেয় “ফাঁক পূরণ”, “ঠিক একবার” নয়। টেকসই ইতিহাস, transaction-এ লেখা event আর দুবার এলেও নিরাপদ client, এই তিনটি মিলে তবেই নির্ভরযোগ্য real-time.

প্র ০৪দশ হাজার খোলা SSE সংযোগ একটি Node.js server-এর জন্য কতটা ভারী? কোথায় আসল খরচ লুকিয়ে থাকে?

প্রথমে ভালো খবর। নীরব SSE সংযোগ CPU প্রায় খরচ করে না। Node.js-এর event loop কোনো সংযোগের জন্য আলাদা thread রাখে না; প্রতিটি সংযোগ মানে একটি socket, কিছু object আর buffer. ১৯৯৯ সালে Dan Kegel যখন “C10K problem” লেখেন, তখন এক server-এ দশ হাজার সংযোগ ছিল বড় চ্যালেঞ্জ, কারণ প্রচলিত server প্রতি সংযোগে একটি thread বা process রাখত। Event-driven নকশা, যেমন nginx আর Node.js, সেই সমস্যার উত্তর হিসেবেই জনপ্রিয় হয়।

তাহলে খরচ কোথায়? প্রথমত, প্রতি সংযোগের memory: socket-এর kernel buffer, Node-এর stream object, আপনার নিজের closure আর listener. সংখ্যাটি নির্দিষ্ট নয়; নিজের অ্যাপে মেপে নিতে হয়। দ্বিতীয়ত, ধীর client: এই পর্বের backpressure run-এ একটি পড়া বন্ধ client দেড় সেকেন্ডে ২.৭ MB জমিয়েছে। Buffer-এর সীমা না থাকলে কয়েকটি খারাপ সংযোগই server নামিয়ে দিতে পারে। তৃতীয়ত, fan-out: একটি জনপ্রিয় ঘটনা (যেমন ফ্ল্যাশ সেলের শুরু) দশ হাজার সংযোগে একসাথে লিখতে হয়; এটি CPU-র ঝাঁকুনি।

চতুর্থ আর প্রায়ই ভুলে যাওয়া খরচ হলো reconnect storm. Deploy বা crash-এ সব সংযোগ একসাথে কাটে, আর সবাই retry-এর একই সময় পরে একসাথে ফেরে। প্রতিটি ফেরা মানে auth যাচাই আর ইতিহাস থেকে ফাঁক পূরণ, অর্থাৎ database-এ হঠাৎ দশ হাজার query. সমাধান হলো server-এর দিক থেকে retry-এ এলোমেলো মান দেওয়া, ধাপে ধাপে deploy, আর সংযোগ সরানোর সময় অল্প অল্প করে বন্ধ করা। এর বিস্তার System Design-এর real-time পর্বে।

আর শেষে, অবকাঠামো: load balancer আর proxy-র নিজেরও সংযোগের সীমা আর idle timeout আছে, আর অপারেটিং সিস্টেমে খোলা ফাইলের সীমা (file descriptor) বাড়াতে হতে পারে। দীর্ঘ সংযোগ বহু instance-এ ছড়াতে Redis-এর মতো pub/sub লাগে, যাতে যেকোনো instance-এর ঘটনা সঠিক সংযোগে পৌঁছায়।

মূল উপলব্ধি: নীরব সংযোগ সস্তা; দামি হলো ধীর client, একসাথে লেখা আর একসাথে ফেরা। সংখ্যা অনুমান না করে নিজের server-এ মাপুন, আর প্রতিটি buffer-এ সীমা রাখুন।

প্র ০৫AI-র উত্তর stream করা কি শুধু দেখতে সুন্দর লাগার জন্য? Streaming-এর সাথে কোন নতুন ঝুঁকি আর দায়িত্ব আসে?

Streaming-এর প্রধান লাভ মনস্তাত্ত্বিক আর পরিমাপযোগ্য দুটোই। মোট সময় একই থাকে, কিন্তু প্রথম অক্ষর দেখার সময় (time to first token) অনেক কমে। Usability গবেষণায় বহুদিন ধরে বলা হয়, প্রায় এক সেকেন্ডের মধ্যে সাড়া না পেলে ব্যবহারকারীর চিন্তার ধারা ভাঙে, আর দশ সেকেন্ডের বেশি অপেক্ষায় মনোযোগ চলে যায়; Jakob Nielsen-এর ১৯৯০-এর দশকের লেখা এই সীমাগুলোর জন্য প্রায়ই উদ্ধৃত হয়। Streaming এই সীমার ভেতরে কিছু দেখায়, আর ব্যবহারকারী উত্তর ভুল দিকে যাচ্ছে দেখলে আগেই থামাতে পারেন।

কিন্তু নতুন দায়িত্বও আসে। প্রথমত, বাতিলের পুরো পথ: এই পর্বের run-এ abort-এ server-এর উৎপাদন থেমেছে, কারণ cancel() লেখা ছিল। বাস্তবে আপনার server আরেকটি provider-কে ডাকে; সেখানে বাতিল না পৌঁছালে কেউ পড়ছে না এমন token-এর খরচ চলতেই থাকে। দ্বিতীয়ত, আংশিক লেখা যাচাই: পুরো উত্তর পেয়ে তারপর নিরাপত্তা বা নীতির পরীক্ষা করলে streaming-এর লাভ নেই; আবার প্রতিটি token আলাদা পরীক্ষা করা যায় না। তাই অনেক ব্যবস্থা ছোট ছোট অংশে যাচাই করে, বা ঝুঁকিপূর্ণ কিছু ধরা পড়লে আগে দেখানো অংশ সরিয়ে নেয়। তৃতীয়ত, render-এর নিরাপত্তা: AI-র লেখা Markdown বা HTML সরাসরি DOM-এ বসালে XSS-এর দরজা খোলে, কারণ ব্যবহারকারীর দেওয়া লেখা prompt-এর মধ্য দিয়ে উত্তরে চলে আসতে পারে।

চতুর্থত, অর্ধেক অবস্থা। Network কাটলে উত্তর অর্ধেক থেকে যায়। একে সম্পূর্ণ উত্তর হিসেবে সংরক্ষণ করবেন, না “অসম্পূর্ণ” চিহ্ন দেবেন? Chat-এর ইতিহাসে কোনটি যাবে? এগুলো পণ্যের সিদ্ধান্ত, আর done event না আসা পর্যন্ত উত্তরকে অসম্পূর্ণ ধরাই নিরাপদ। পঞ্চমত, accessibility: প্রতিটি token-এ aria-live ঘোষণা screen reader-কে অচল করে।

মূল উপলব্ধি: Streaming শুধু UI-র সৌন্দর্য নয়, একটি চুক্তি: অর্ধেক অবস্থা, বাতিল, যাচাই আর নিরাপদ render, চারটিরই পরিকল্পনা লাগে। “থামান” বোতাম তখনই সৎ, যখন তা server আর provider পর্যন্ত কাজ থামায়।

অনুশীলন

হিসাব, কমান্ড, সমালোচনা আর আউটপুট অনুমান, চারটিই আছে।

  1. খরচের হিসাব: ঈদের আগে বাজারঘরে একসাথে ৩,০০০টি অর্ডার পাতা খোলা। (ক) প্রতি ৪ সেকেন্ডে polling হলে server-এ অনুরোধের হার আর গড় দেরি কত? (খ) Long polling-এ timeout ২৫ সেকেন্ড, প্রতিটি অর্ডারে ঘণ্টায় গড়ে ৩টি বদল; প্রতি ঘণ্টায় একটি পাতা প্রায় কতটি অনুরোধ পাঠায়, আর server-এ মোট হার কত? (গ) SSE-তে প্রতি ২০ সেকেন্ডে ৮ byte-এর heartbeat; এক ঘণ্টায় সব পাতা মিলে heartbeat-এর মোট byte কত (header বাদে)?

    (ক) 3000 / 4 = ৭৫০ অনুরোধ/সেকেন্ড; গড় দেরি 4 / 2 = ২ সেকেন্ড, সবচেয়ে খারাপ প্রায় ৪ সেকেন্ড। (খ) Timeout-এর চক্র 3600 / 25 = ১৪৪টি, আর প্রতিটি বদল একটি চক্র আগে ভাঙে, তাই প্রায় 144 + 3 = ১৪৭টি অনুরোধ। মোট হার 3000 × 147 / 3600 ≈ ১২২.৫ অনুরোধ/সেকেন্ড, সাথে সবসময় ৩,০০০টি খোলা সংযোগ। (গ) প্রতি পাতায় 3600 / 20 = ১৮০টি heartbeat × 8 = 1,440 byte; 3000 পাতায় 4,320,000 byte ≈ ৪.৩ MB প্রতি ঘণ্টায়। Polling-এর ৭৫০ অনুরোধ/সেকেন্ডের প্রতিটির header-ই এর চেয়ে অনেক বেশি।

  2. Buffering ধরার কমান্ড: Production-এ অভিযোগ এসেছে, অর্ডারের খবর কখনো ৩০ সেকেন্ড দেরিতে, একসাথে কয়েকটি আসে। কোন curl কমান্ড চালাবেন, আর উত্তরের কোন কোন জিনিস দেখে কারণ বের করবেন?

    curl -N -i -H 'Accept-Encoding: gzip' https://…/api/orders/42/events. -N curl-এর নিজের buffering বন্ধ করে, -i header দেখায়। দেখবেন: (১) Content-Encoding: gzip থাকলে compression সন্দেহ; (২) header সাথে সাথে আসে কি না, আর retry/প্রথম event তখনই আসে কি না; (৩) heartbeat : ping ঠিক বিরতিতে আসে কি না; না এলে মাঝের proxy buffer করছে। তারপর একই কমান্ড proxy এড়িয়ে সরাসরি app server-এ (ভেতরের network থেকে) চালান; সেখানে ঠিক থাকলে দোষ proxy-র, তখন X-Accel-Buffering: no পৌঁছাচ্ছে কি না বা proxy-র নিজের নিয়ম দেখুন।

  3. কোড সমালোচনা (নকশা): অন্তত পাঁচটি সমস্যা ধরুন: app.use(compression()); app.get('/events', (req, res) => { const user = jwt.verify(req.query.token, SECRET); res.setHeader('Content-Type', 'text/event-stream'); setInterval(async () => { const o = await db.order.findMany({ where: { userId: user.id } }); res.write('data: ' + JSON.stringify(o) + '\n\n'); }, 1000); });
    • Token URL-এ: log, proxy আর history-তে ফাঁস হয়। Cookie বা স্বল্পায়ুর ticket ভালো।
    • Global compression(): SSE আটকে যাবে, এই পর্বের run-এ ২ সেকেন্ডে একটি byte-ও আসেনি।
    • req.on('close')-এ clearInterval নেই: client চলে গেলেও প্রতি সেকেন্ডে query চলতে থাকবে, leak.
    • এটি আসলে server-এর ভেতরে polling: প্রতি সংযোগে প্রতি সেকেন্ডে একটি database query. ১,০০০ সংযোগে ১,০০০ query/সেকেন্ড। বদল হলে pub/sub দিয়ে পাঠানো উচিত।
    • id নেই, Last-Event-ID পড়া নেই, heartbeat নেই, Cache-Control: no-cache নেই।
    • পুরো অর্ডারের তালিকা প্রতিবার পাঠানো, বদল না হলেও; আর jwt.verify ব্যর্থ হলে ধরা হয়নি, তাই অনিয়ন্ত্রিত error.
  4. আউটপুট অনুমান: একটি পাতায় es.onmessage = (e) => console.log('msg', e.data, e.lastEventId) আর es.addEventListener('status', (e) => console.log('status', e.data, e.lastEventId)). Server ঠিক এই byte পাঠিয়ে সংযোগ বন্ধ করল (প্রতিটি লাইনের শেষে newline): : hello, ফাঁকা, id: 5, data: a, data: b, ফাঁকা, event: status, data: packed, ফাঁকা, id: 6, data: shipped (এর পরে আর ফাঁকা লাইন নেই)। Console-এ কী কী ছাপা হবে?

    দুটি লাইন: প্রথমে msg a\nb 5 (অর্থাৎ “a” আর “b” দুই লাইনে, lastEventId 5), তারপর status packed 5. Comment : hello কিছুই ছাপে না। দ্বিতীয় event-এ নিজের id নেই, কিন্তু browser-এর মনে রাখা id 5-ই থাকে। শেষ অংশে ফাঁকা লাইন নেই, তাই মান অনুযায়ী সেটি অসম্পূর্ণ event, ফেলে দেওয়া হয়; এই সংযোগে “shipped” ছাপা হয় না। HTML মানে id লাইন প্রথমে একটি অস্থায়ী জায়গায় বসে, আর event পাঠানোর মুহূর্তে (ফাঁকা লাইনে) তবেই browser-এর শেষ id হয়। তাই শেষ id 5-ই থাকে। সংযোগ বন্ধ হওয়ায় browser reconnect করবে Last-Event-ID: 5 নিয়ে, আর server-এর ইতিহাস থাকলে id 6 shipped তখন আবার আসবে। শিক্ষা: প্রতিটি event-এর শেষে ফাঁকা লাইন দিন; না দিলে event সেই সংযোগে পৌঁছায় না।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমঅর্ডারের অবস্থা বদলানোর পরে একটি SSE event পর্দায় পৌঁছানোর ধাপগুলো সাজান (▲▼ চেপে সরান)

  1. Server event store-এ নতুন id দিয়ে ঘটনাটি লেখে
  2. Server id, event, data লাইন আর একটি ফাঁকা লাইন লেখে
  3. Byte-গুলো buffering ছাড়া proxy পার হয়
  4. Browser ফাঁকা লাইন পেয়ে event পাঠায় আর lastEventId বদলায়
  5. Listener-এ setState, React নতুন অবস্থা দেখায়

SSE সংযোগ কেটে গেলে browser আবার সংযোগের সময় কোন header-এ জানায় যে সে কোন event পর্যন্ত পেয়েছে?

  • If-None-Match
  • Last-Event-ID
  • Range
  • X-Event-Cursor

HTTP/1.1-এ একটি পাতা একই host-এ ৮টি EventSource খোলে। Chromium-ভিত্তিক browser-এ সাধারণত কী ঘটে?

  • ৮টিই একটি TCP সংযোগে একসাথে চলে
  • ৭ম আর ৮মটি সাথে সাথে error দিয়ে বন্ধ হয়
  • ৬টি খোলে, বাকি দুটি আর ওই host-এর অন্য অনুরোধও অপেক্ষায় আটকে থাকে
  • Browser নিজে থেকে WebSocket-এ চলে যায়

SSE stream-এ কোন field browser-কে বলে দেয়, সংযোগ কাটলে আবার চেষ্টার আগে কত millisecond অপেক্ষা করবে?

  • retry
  • id
  • : timeout
  • keep-alive

Browser-এর EventSource দিয়ে নিচের কোনটি করা যায় না?

  • একই origin-এ session cookie পাঠানো
  • অন্য origin-এ withCredentials দিয়ে cookie পাঠানো
  • নাম দেওয়া event আলাদা listener-এ শোনা
  • Authorization: Bearer header বসানো

১,২০০টি খোলা পাতা প্রতিটি TanStack Query-র refetchInterval: 5000 দিয়ে polling করছে, সব tab সামনে খোলা। Server-এ প্রতি সেকেন্ডে প্রায় কতটি অনুরোধ আসে?

  • ১২
  • ২৪০
  • ৬,০০০
  • ১৪,৪০০

Express-এ global compression() middleware আছে, আর SSE endpoint-এর event client-এ পৌঁছাচ্ছে না। নিচের কোনটি কার্যকর সমাধান?

  • Heartbeat আরও ঘন করা
  • Content-Type বদলে text/plain করা
  • উত্তরে Cache-Control: no-transform দেওয়া, বা প্রতিটি লেখার পরে res.flush()
  • retry: 0 পাঠানো

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Polling-এর হার N / T, গড় দেরি T / 2; long polling অনুরোধ কমায় কিন্তু সংযোগ ধরে রাখে; SSE আর WebSocket একটি সংযোগে প্রায় সাথে সাথে খবর দেয়।
  2. SSE = text/event-stream-এ data, event, id, retry, ফাঁকা লাইনে event শেষ, : দিয়ে heartbeat.
  3. EventSource নিজে reconnect করে আর Last-Event-ID পাঠায়; টেকসই ইতিহাস থাকলে তবেই কিছু হারায় না, তবু client-কে দুবার আসা সামলাতে হবে।
  4. বাধা: HTTP/1.1-এ ৬ সংযোগ, proxy buffering, compression, ধীর client; প্রতিকার HTTP/2, X-Accel-Buffering: no, compression বাদ বা flush, আর drain.
  5. AI stream: fetch POST + reader, আংশিক লেখা React-এ, AbortController; বাতিল server আর provider পর্যন্ত পৌঁছাতে হবে, আর EventSource-এ নিজের header যায় না।

পরের পর্বে: অর্ডার shipped হলে ক্রেতাকে email, বিক্রেতাকে রিপোর্ট, ছবি ছোট করা, এসব কাজ অনুরোধের ভেতরে করা যায় না। পর্ব ৬২-এ background job: queue, retry আর cron.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান