{"id":899,"date":"1970-01-01T00:00:00","date_gmt":"1970-01-01T00:00:00","guid":{"rendered":"https:\/\/imcodinggenius.com\/?p=899"},"modified":"1970-01-01T00:00:00","modified_gmt":"1970-01-01T00:00:00","slug":"building-a-real-time-ai-chat-feature-in-node-js-using-openais-streaming-api","status":"publish","type":"post","link":"https:\/\/imcodinggenius.com\/?p=899","title":{"rendered":"Building a Real-Time AI Chat Feature in Node.js Using OpenAI&#8217;s Streaming API"},"content":{"rendered":"<p>I almost shipped a bad AI integration once. The feature worked technically: the model answered questions correctly, the UI looked clean, and everyone nodded along in our internal demo. Then we put real users on it, and within 48 hours a product manager Slack&#8217;d me asking why 60% of sessions were ending with no response received.<\/p>\n<p>The problem wasn&#8217;t the API or the server. It was eight seconds of silence.<\/p>\n<p>Users sent a message, saw nothing happen, assumed the request had failed, and closed the tab. The response would arrive shortly after, to nobody. We had built a functional AI feature that felt completely broken, and the fix wasn&#8217;t an architectural overhaul or a performance optimization. It was streaming: start sending tokens the moment the model produces them, instead of batching the full response and delivering it at once.<\/p>\n<p>I have seen this mistake in codebases across multiple companies now. The non-streaming version is simpler to write, tests pass, and nobody notices in development because developers wait for things, while real users usually do not.<\/p>\n<p>This tutorial builds the complete streaming implementation: a Node.js backend using Server-Sent Events to push tokens to the browser as they arrive, with session management that gives the model memory across turns, and proper abort handling so you are not paying for tokens nobody reads.<\/p>\n<p>The complete code is at <a href=\"https:\/\/github.com\/ziaongit\/nodejs-openai-streaming\">github.com\/ziaongit\/nodejs-openai-streaming<\/a>.<\/p>\n<p><strong>No OpenAI key?<\/strong> I tested this entire article using <a href=\"https:\/\/console.groq.com\/\">Groq<\/a> instead. It is free, it speaks the same OpenAI SDK format, and getting a key takes about two minutes. The repo has a USE_GROQ=true flag already wired in. Copy .env.example to .env, paste your Groq key, and you are running. I used llama-3.3-70b-versatile. The output is good enough that you would not notice the difference in a dev session.<\/p>\n<h2>Prerequisites<\/h2>\n<p>Node.js v18.11 or later<br \/>\nAn OpenAI API key<br \/>\nSolid familiarity with Express and async\/await<\/p>\n<h2>The Architecture Before Any Code<\/h2>\n<p>Before we write any code, it helps to see what is actually happening across the three layers. Debugging streaming issues without this picture gets painful fast.<\/p>\n<p>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510<\/p>\n<p>\u2502                           Browser                                \u2502<\/p>\n<p>\u2502                                                                  \u2502<\/p>\n<p>\u2502  1. POST \/session  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25ba receives sessionId     \u2502<\/p>\n<p>\u2502  2. POST \/chat { message, sessionId }                            \u2502<\/p>\n<p>\u2502  3. response.body.getReader() \u2500\u2500\u25ba ReadableStream                 \u2502<\/p>\n<p>\u2502  4. decode chunks \u2500\u2500\u25ba parse SSE events \u2500\u2500\u25ba append tokens to UI   \u2502<\/p>\n<p>\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518<\/p>\n<p>                         \u2502  HTTP  (text\/event-stream)<\/p>\n<p>                         \u25bc<\/p>\n<p>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510<\/p>\n<p>\u2502                       Express Server                             \u2502<\/p>\n<p>\u2502                                                                  \u2502<\/p>\n<p>\u2502  sessions Map  \u2500\u2500  { sessionId: { messages: [&#8230;] } }            \u2502<\/p>\n<p>\u2502                                                                  \u2502<\/p>\n<p>\u2502  POST \/chat                                                      \u2502<\/p>\n<p>\u2502    \u251c\u2500 load session history                                       \u2502<\/p>\n<p>\u2502    \u251c\u2500 push user message                                          \u2502<\/p>\n<p>\u2502    \u251c\u2500 set SSE headers + flushHeaders()                           \u2502<\/p>\n<p>\u2502    \u251c\u2500 openai.create({ stream: true, signal: controller.signal }) \u2502<\/p>\n<p>\u2502    \u251c\u2500 for await chunk \u2192 res.write(`data: ${token}nn`)          \u2502<\/p>\n<p>\u2502    \u251c\u2500 accumulate fullResponse \u2192 push to session                  \u2502<\/p>\n<p>\u2502    \u2514\u2500 send { done: true } \u2192 res.end()                            \u2502<\/p>\n<p>\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518<\/p>\n<p>                         \u2502  HTTPS chunked transfer encoding<\/p>\n<p>                         \u25bc<\/p>\n<p>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510<\/p>\n<p>\u2502                        OpenAI API                                \u2502<\/p>\n<p>\u2502              gpt-4o-mini  \u00b7  stream: true                        \u2502<\/p>\n<p>\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518<\/p>\n<p>The session store lives on the server. Every time the user sends a message, that full conversation history goes to OpenAI inside the messages array, which is how the model knows what was said earlier. The API itself remembers nothing between calls, so you carry the state.<\/p>\n<p>The mechanism is simpler than it sounds: open a regular HTTP response and never close it. The server keeps writing chunks into the socket as tokens arrive, and when the model finishes you call res.end() and the connection drops.<\/p>\n<h2>Project Setup<\/h2>\n<p>mkdir nodejs-openai-streaming<\/p>\n<p><span class=\"hljs-built_in\">cd<\/span> nodejs-openai-streaming<\/p>\n<p>npm init -y<\/p>\n<p>npm install express openai cors dotenv<\/p>\n<p>mkdir public<\/p>\n<p>Add `<span class=\"hljs-string\">&#171;type&#187;<\/span>: <span class=\"hljs-string\">&#171;module&#187;<\/span>` to `package.json`:<\/p>\n<p>{<\/p>\n<p>  <span class=\"hljs-attr\">&#171;name&#187;<\/span>: <span class=\"hljs-string\">&#171;nodejs-openai-streaming&#187;<\/span>,<\/p>\n<p>  <span class=\"hljs-attr\">&#171;version&#187;<\/span>: <span class=\"hljs-string\">&#171;1.0.0&#187;<\/span>,<\/p>\n<p>  <span class=\"hljs-attr\">&#171;type&#187;<\/span>: <span class=\"hljs-string\">&#171;module&#187;<\/span>,<\/p>\n<p>  <span class=\"hljs-attr\">&#171;scripts&#187;<\/span>: {<\/p>\n<p>    <span class=\"hljs-attr\">&#171;start&#187;<\/span>: <span class=\"hljs-string\">&#171;node server.js&#187;<\/span>,<\/p>\n<p>    <span class=\"hljs-attr\">&#171;dev&#187;<\/span>: <span class=\"hljs-string\">&#171;node &#8212;watch server.js&#187;<\/span><\/p>\n<p>  },<\/p>\n<p>  <span class=\"hljs-attr\">&#171;dependencies&#187;<\/span>: {<\/p>\n<p>    <span class=\"hljs-attr\">&#171;cors&#187;<\/span>: <span class=\"hljs-string\">&#171;^2.8.6&#187;<\/span>,<\/p>\n<p>    <span class=\"hljs-attr\">&#171;dotenv&#187;<\/span>: <span class=\"hljs-string\">&#171;^16.6.1&#187;<\/span>,<\/p>\n<p>    <span class=\"hljs-attr\">&#171;express&#187;<\/span>: <span class=\"hljs-string\">&#171;^4.22.2&#187;<\/span>,<\/p>\n<p>    <span class=\"hljs-attr\">&#171;openai&#187;<\/span>: <span class=\"hljs-string\">&#171;^4.104.0&#187;<\/span><\/p>\n<p>  }<\/p>\n<p>}<\/p>\n<p>Create .env:<\/p>\n<p>OPENAI_API_KEY=your_api_key_here<\/p>\n<p>PORT=3000<\/p>\n<p>Project structure:<\/p>\n<p>nodejs-openai-streaming\/<\/p>\n<p>\u251c\u2500\u2500 server.js<\/p>\n<p>\u251c\u2500\u2500 public\/<\/p>\n<p>\u2502   \u2514\u2500\u2500 index.html<\/p>\n<p>\u251c\u2500\u2500 .env<\/p>\n<p>\u251c\u2500\u2500 .env.example<\/p>\n<p>\u251c\u2500\u2500 .gitignore<\/p>\n<p>\u2514\u2500\u2500 package.json<\/p>\n<h2>Why SSE and Not WebSockets<\/h2>\n<p>People ask about WebSockets every time, usually because they sound more capable and therefore more correct.<\/p>\n<p>For AI chat, they usually are not. Responses travel one way, server to client, and SSE is built for exactly that. It runs over plain HTTP with no protocol upgrade and no special reverse proxy configuration, and it works through HTTP\/2 without touching a config file. I have shipped SSE to production multiple times and never once worried about browser support.<\/p>\n<p>WebSockets make sense when the client needs to push data back at the same frequency the server pushes it: gaming, shared whiteboards, live bidding. In an AI chat, the user types one message and sits there reading, so SSE fits the traffic pattern better.<\/p>\n<p>One genuine limitation of SSE worth knowing: the browser&#8217;s built-in EventSource API only supports GET requests. Since you need to post a message body, you use fetch with a ReadableStream reader instead. The SSE wire protocol is identical; you are just reading the stream manually rather than through the browser&#8217;s event wrapper.<\/p>\n<h2>Session Management<\/h2>\n<p>The first chatbot I built on top of this API had a bug I could not figure out for two days. Users would ask a follow-up question and the model would answer like it was hearing from them for the first time, with no context or memory of the earlier turns.<\/p>\n<p>The API does not store anything between calls, so each request goes in cold. The messages array is how you fix that: pack the full conversation history into every request, user turns and assistant turns both, and the model responds as if it remembers the whole thread. Leave it out and you get that same broken experience I shipped by accident.<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> sessions = <span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Map<\/span>();<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> SESSION_TTL_MS = <span class=\"hljs-number\">30<\/span> * <span class=\"hljs-number\">60<\/span> * <span class=\"hljs-number\">1000<\/span>; <span class=\"hljs-comment\">\/\/ 30 minutes<\/span><\/p>\n<p><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getOrCreateSession<\/span>(<span class=\"hljs-params\">sessionId<\/span>) <\/span>{<\/p>\n<p>  <span class=\"hljs-keyword\">if<\/span> (!sessions.has(sessionId)) {<\/p>\n<p>    sessions.set(sessionId, {<\/p>\n<p>      <span class=\"hljs-attr\">messages<\/span>: [],<\/p>\n<p>      <span class=\"hljs-attr\">lastActive<\/span>: <span class=\"hljs-built_in\">Date<\/span>.now(),<\/p>\n<p>    });<\/p>\n<p>  }<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> session = sessions.get(sessionId);<\/p>\n<p>  session.lastActive = <span class=\"hljs-built_in\">Date<\/span>.now();<\/p>\n<p>  <span class=\"hljs-keyword\">return<\/span> session;<\/p>\n<p>}<\/p>\n<p><span class=\"hljs-built_in\">setInterval<\/span>(() =&gt; {<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> now = <span class=\"hljs-built_in\">Date<\/span>.now();<\/p>\n<p>  <span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">const<\/span> [id, session] <span class=\"hljs-keyword\">of<\/span> sessions) {<\/p>\n<p>    <span class=\"hljs-keyword\">if<\/span> (now &#8212; session.lastActive &gt; SESSION_TTL_MS) {<\/p>\n<p>      sessions.delete(id);<\/p>\n<p>    }<\/p>\n<p>  }<\/p>\n<p>}, <span class=\"hljs-number\">10<\/span> * <span class=\"hljs-number\">60<\/span> * <span class=\"hljs-number\">1000<\/span>);<\/p>\n<p>The Map is fine at development scale and in single-process deployments. The lastActive timestamp gets bumped on every request. The cleanup interval runs every ten minutes and removes sessions that have gone quiet for half an hour.<\/p>\n<p>The hard limit of this approach is that a Node.js process restart wipes it entirely. If you run multiple instances behind a load balancer, each instance has its own Map, and a user whose requests hit different instances will lose their conversation mid-sentence. The fix is Redis: one client, same data structure, TTL handled natively. I am not going to build that here because it would double the length of this article without adding anything about streaming, but do not ship this to production without it.<\/p>\n<h2>The Streaming Endpoint<\/h2>\n<p>Four things happen in sequence before tokens start flowing: input validation, SSE setup, abort handling, and the OpenAI call with its token loop. Each step has at least one non-obvious decision.<\/p>\n<h3>SSE Setup<\/h3>\n<p>res.setHeader(<span class=\"hljs-string\">&#8216;Content-Type&#8217;<\/span>, <span class=\"hljs-string\">&#8216;text\/event-stream&#8217;<\/span>);<\/p>\n<p>res.setHeader(<span class=\"hljs-string\">&#8216;Cache-Control&#8217;<\/span>, <span class=\"hljs-string\">&#8216;no-cache&#8217;<\/span>);<\/p>\n<p>res.setHeader(<span class=\"hljs-string\">&#8216;Connection&#8217;<\/span>, <span class=\"hljs-string\">&#8216;keep-alive&#8217;<\/span>);<\/p>\n<p>res.flushHeaders();<\/p>\n<p>flushHeaders() is the line I see missing in more streaming PRs than I can count. Skip it and Express holds onto the response headers until the first res.write() call. The browser opens the request, gets nothing back, sits there, and after a few seconds the whole thing looks like a hung request. You will spend an hour checking your OpenAI call, your async logic, and your middleware, and the fix is one line at the top. Call flushHeaders() right after setting the headers; nothing else changes.<\/p>\n<h3>AbortController<\/h3>\n<p><span class=\"hljs-keyword\">const<\/span> controller = <span class=\"hljs-keyword\">new<\/span> AbortController();<\/p>\n<p>res.on(<span class=\"hljs-string\">&#8216;close&#8217;<\/span>, () =&gt; {<\/p>\n<p>  <span class=\"hljs-keyword\">if<\/span> (!res.writableEnded) controller.abort();<\/p>\n<p>});<\/p>\n<p>One important detail: attach the close listener to res, not req. The req object fires close when the request body is fully consumed, which happens immediately after the POST body arrives and would abort the stream before it starts. res.on(&#8216;close&#8217;) fires when the actual response connection drops, which is what you want. The !res.writableEnded guard prevents calling abort() after a clean finish.<\/p>\n<p>When the connection drops, controller.abort() propagates a cancellation signal to the OpenAI SDK and the for await loop exits on the next iteration. Without this, you keep pulling tokens from the API and writing them into a closed socket, which means you are spending money and achieving nothing.<\/p>\n<h3>The OpenAI Streaming Call<\/h3>\n<p><span class=\"hljs-keyword\">const<\/span> stream = <span class=\"hljs-keyword\">await<\/span> openai.chat.completions.create(<\/p>\n<p>  {<\/p>\n<p>    <span class=\"hljs-attr\">model<\/span>: <span class=\"hljs-string\">&#8216;gpt-4o-mini&#8217;<\/span>,<\/p>\n<p>    <span class=\"hljs-attr\">messages<\/span>: [<\/p>\n<p>      {<\/p>\n<p>        <span class=\"hljs-attr\">role<\/span>: <span class=\"hljs-string\">&#8216;system&#8217;<\/span>,<\/p>\n<p>        <span class=\"hljs-attr\">content<\/span>: <span class=\"hljs-string\">&#8216;You are a helpful assistant. Be concise and clear.&#8217;<\/span>,<\/p>\n<p>      },<\/p>\n<p>      &#8230;session.messages,<\/p>\n<p>    ],<\/p>\n<p>    <span class=\"hljs-attr\">stream<\/span>: <span class=\"hljs-literal\">true<\/span>,<\/p>\n<p>    max_tokens: <span class=\"hljs-number\">800<\/span>,<\/p>\n<p>  },<\/p>\n<p>  { <span class=\"hljs-attr\">signal<\/span>: controller.signal }<\/p>\n<p>);<\/p>\n<p>Set max_tokens. This is not optional. I left it uncapped during a testing session and watched my usage dashboard spike in real time. Left alone, the model will write a dissertation in response to a three-word question. I use 800 for chat, which works fine for most things, and you can tune it once you see what your users actually send.<\/p>\n<p>The signal parameter threads the AbortController through to the SDK. If the client disconnects, the SDK cancels the underlying HTTP request to OpenAI, not just the local loop.<\/p>\n<h3>Token Loop and History<\/h3>\n<p><span class=\"hljs-keyword\">let<\/span> fullResponse = <span class=\"hljs-string\">&#187;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">for<\/span> <span class=\"hljs-keyword\">await<\/span> (<span class=\"hljs-keyword\">const<\/span> chunk <span class=\"hljs-keyword\">of<\/span> stream) {<\/p>\n<p>  <span class=\"hljs-keyword\">if<\/span> (req.socket.destroyed) <span class=\"hljs-keyword\">break<\/span>;<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> token = chunk.choices[<span class=\"hljs-number\">0<\/span>]?.delta?.content;<\/p>\n<p>  <span class=\"hljs-keyword\">if<\/span> (token) {<\/p>\n<p>    fullResponse += token;<\/p>\n<p>    res.write(<span class=\"hljs-string\">`data: <span class=\"hljs-subst\">${<span class=\"hljs-built_in\">JSON<\/span>.stringify({ token })}<\/span>nn`<\/span>);<\/p>\n<p>  }<\/p>\n<p>}<\/p>\n<p><span class=\"hljs-keyword\">if<\/span> (fullResponse) {<\/p>\n<p>  session.messages.push({ <span class=\"hljs-attr\">role<\/span>: <span class=\"hljs-string\">&#8216;assistant&#8217;<\/span>, <span class=\"hljs-attr\">content<\/span>: fullResponse });<\/p>\n<p>}<\/p>\n<p>Each chunk contains one token in delta.content. You concatenate them into fullResponse as they arrive, relay each one to the client, and save the complete string to the session once the loop ends.<\/p>\n<p>The double newline in res.write() is mandatory; it is how SSE signals the end of a single event. The browser buffers data until it sees nn before firing. Omit it and the client receives all tokens at once when the connection closes, which is the same broken behavior as not streaming at all.<\/p>\n<p>Do not save fullResponse if the stream was aborted. A truncated assistant reply in the session history will corrupt the conversation context, and the model&#8217;s next turn will be built on incomplete information.<\/p>\n<h2>The Complete server.js<\/h2>\n<p><span class=\"hljs-keyword\">import<\/span> <span class=\"hljs-string\">&#8216;dotenv\/config&#8217;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">import<\/span> express <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">&#8216;express&#8217;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">import<\/span> cors <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">&#8216;cors&#8217;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">import<\/span> OpenAI <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">&#8216;openai&#8217;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">import<\/span> path <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">&#8216;path&#8217;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">import<\/span> crypto <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">&#8216;crypto&#8217;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">import<\/span> { fileURLToPath } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">&#8216;url&#8217;<\/span>;<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> __filename = fileURLToPath(<span class=\"hljs-keyword\">import<\/span>.meta.url);<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> __dirname = path.dirname(__filename);<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> app = express();<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> openai = <span class=\"hljs-keyword\">new<\/span> OpenAI({ <span class=\"hljs-attr\">apiKey<\/span>: process.env.OPENAI_API_KEY });<\/p>\n<p>app.use(cors());<\/p>\n<p>app.use(express.json());<\/p>\n<p>app.use(express.static(path.join(__dirname, <span class=\"hljs-string\">&#8216;public&#8217;<\/span>)));<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> sessions = <span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Map<\/span>();<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> SESSION_TTL_MS = <span class=\"hljs-number\">30<\/span> * <span class=\"hljs-number\">60<\/span> * <span class=\"hljs-number\">1000<\/span>;<\/p>\n<p><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getOrCreateSession<\/span>(<span class=\"hljs-params\">sessionId<\/span>) <\/span>{<\/p>\n<p>  <span class=\"hljs-keyword\">if<\/span> (!sessions.has(sessionId)) {<\/p>\n<p>    sessions.set(sessionId, { <span class=\"hljs-attr\">messages<\/span>: [], <span class=\"hljs-attr\">lastActive<\/span>: <span class=\"hljs-built_in\">Date<\/span>.now() });<\/p>\n<p>  }<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> session = sessions.get(sessionId);<\/p>\n<p>  session.lastActive = <span class=\"hljs-built_in\">Date<\/span>.now();<\/p>\n<p>  <span class=\"hljs-keyword\">return<\/span> session;<\/p>\n<p>}<\/p>\n<p><span class=\"hljs-built_in\">setInterval<\/span>(<span class=\"hljs-function\">() =&gt;<\/span> {<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> now = <span class=\"hljs-built_in\">Date<\/span>.now();<\/p>\n<p>  <span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">const<\/span> [id, session] <span class=\"hljs-keyword\">of<\/span> sessions) {<\/p>\n<p>    <span class=\"hljs-keyword\">if<\/span> (now &#8212; session.lastActive &gt; SESSION_TTL_MS) sessions.delete(id);<\/p>\n<p>  }<\/p>\n<p>}, <span class=\"hljs-number\">10<\/span> * <span class=\"hljs-number\">60<\/span> * <span class=\"hljs-number\">1000<\/span>);<\/p>\n<p>app.post(<span class=\"hljs-string\">&#8216;\/session&#8217;<\/span>, <span class=\"hljs-function\">(<span class=\"hljs-params\">req, res<\/span>) =&gt;<\/span> {<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> sessionId = crypto.randomUUID();<\/p>\n<p>  getOrCreateSession(sessionId);<\/p>\n<p>  <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">`[\/session] created: <span class=\"hljs-subst\">${sessionId}<\/span>`<\/span>);<\/p>\n<p>  res.json({ sessionId });<\/p>\n<p>});<\/p>\n<p>app.delete(<span class=\"hljs-string\">&#8216;\/session\/:sessionId&#8217;<\/span>, <span class=\"hljs-function\">(<span class=\"hljs-params\">req, res<\/span>) =&gt;<\/span> {<\/p>\n<p>  sessions.delete(req.params.sessionId);<\/p>\n<p>  res.json({ <span class=\"hljs-attr\">ok<\/span>: <span class=\"hljs-literal\">true<\/span> });<\/p>\n<p>});<\/p>\n<p>app.post(<span class=\"hljs-string\">&#8216;\/chat&#8217;<\/span>, <span class=\"hljs-keyword\">async<\/span> (req, res) =&gt; {<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> { message, sessionId } = req.body;<\/p>\n<p>  <span class=\"hljs-keyword\">if<\/span> (!message || <span class=\"hljs-keyword\">typeof<\/span> message !== <span class=\"hljs-string\">&#8216;string&#8217;<\/span>) {<\/p>\n<p>    <span class=\"hljs-keyword\">return<\/span> res.status(<span class=\"hljs-number\">400<\/span>).json({ <span class=\"hljs-attr\">error<\/span>: <span class=\"hljs-string\">&#8216;message is required&#8217;<\/span> });<\/p>\n<p>  }<\/p>\n<p>  <span class=\"hljs-keyword\">if<\/span> (!sessionId || <span class=\"hljs-keyword\">typeof<\/span> sessionId !== <span class=\"hljs-string\">&#8216;string&#8217;<\/span>) {<\/p>\n<p>    <span class=\"hljs-keyword\">return<\/span> res.status(<span class=\"hljs-number\">400<\/span>).json({ <span class=\"hljs-attr\">error<\/span>: <span class=\"hljs-string\">&#8216;sessionId is required&#8217;<\/span> });<\/p>\n<p>  }<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> session = getOrCreateSession(sessionId);<\/p>\n<p>  session.messages.push({ <span class=\"hljs-attr\">role<\/span>: <span class=\"hljs-string\">&#8216;user&#8217;<\/span>, <span class=\"hljs-attr\">content<\/span>: message });<\/p>\n<p>  <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">`[\/chat] received:`<\/span>, { message, sessionId });<\/p>\n<p>  res.setHeader(<span class=\"hljs-string\">&#8216;Content-Type&#8217;<\/span>, <span class=\"hljs-string\">&#8216;text\/event-stream&#8217;<\/span>);<\/p>\n<p>  res.setHeader(<span class=\"hljs-string\">&#8216;Cache-Control&#8217;<\/span>, <span class=\"hljs-string\">&#8216;no-cache&#8217;<\/span>);<\/p>\n<p>  res.setHeader(<span class=\"hljs-string\">&#8216;Connection&#8217;<\/span>, <span class=\"hljs-string\">&#8216;keep-alive&#8217;<\/span>);<\/p>\n<p>  res.flushHeaders();<\/p>\n<p>  <span class=\"hljs-keyword\">const<\/span> controller = <span class=\"hljs-keyword\">new<\/span> AbortController();<\/p>\n<p>  res.on(<span class=\"hljs-string\">&#8216;close&#8217;<\/span>, <span class=\"hljs-function\">() =&gt;<\/span> {<\/p>\n<p>    <span class=\"hljs-keyword\">if<\/span> (!res.writableEnded) controller.abort();<\/p>\n<p>  });<\/p>\n<p>  <span class=\"hljs-keyword\">let<\/span> fullResponse = <span class=\"hljs-string\">&#187;<\/span>;<\/p>\n<p>  <span class=\"hljs-keyword\">try<\/span> {<\/p>\n<p>    <span class=\"hljs-keyword\">const<\/span> stream = <span class=\"hljs-keyword\">await<\/span> openai.chat.completions.create(<\/p>\n<p>      {<\/p>\n<p>        <span class=\"hljs-attr\">model<\/span>: <span class=\"hljs-string\">&#8216;gpt-4o-mini&#8217;<\/span>,<\/p>\n<p>        <span class=\"hljs-attr\">messages<\/span>: [<\/p>\n<p>          { <span class=\"hljs-attr\">role<\/span>: <span class=\"hljs-string\">&#8216;system&#8217;<\/span>, <span class=\"hljs-attr\">content<\/span>: <span class=\"hljs-string\">&#8216;You are a helpful assistant. Be concise and clear.&#8217;<\/span> },<\/p>\n<p>          &#8230;session.messages,<\/p>\n<p>        ],<\/p>\n<p>        <span class=\"hljs-attr\">stream<\/span>: <span class=\"hljs-literal\">true<\/span>,<\/p>\n<p>        <span class=\"hljs-attr\">max_tokens<\/span>: <span class=\"hljs-number\">800<\/span>,<\/p>\n<p>      },<\/p>\n<p>      { <span class=\"hljs-attr\">signal<\/span>: controller.signal }<\/p>\n<p>    );<\/p>\n<p>    <span class=\"hljs-keyword\">for<\/span> <span class=\"hljs-keyword\">await<\/span> (<span class=\"hljs-keyword\">const<\/span> chunk <span class=\"hljs-keyword\">of<\/span> stream) {<\/p>\n<p>      <span class=\"hljs-keyword\">if<\/span> (req.socket.destroyed) <span class=\"hljs-keyword\">break<\/span>;<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> token = chunk.choices[<span class=\"hljs-number\">0<\/span>]?.delta?.content;<\/p>\n<p>      <span class=\"hljs-keyword\">if<\/span> (token) {<\/p>\n<p>        fullResponse += token;<\/p>\n<p>        res.write(<span class=\"hljs-string\">`data: <span class=\"hljs-subst\">${<span class=\"hljs-built_in\">JSON<\/span>.stringify({ token })}<\/span>nn`<\/span>);<\/p>\n<p>      }<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-keyword\">if<\/span> (fullResponse) {<\/p>\n<p>      session.messages.push({ <span class=\"hljs-attr\">role<\/span>: <span class=\"hljs-string\">&#8216;assistant&#8217;<\/span>, <span class=\"hljs-attr\">content<\/span>: fullResponse });<\/p>\n<p>      <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">`[stream complete] <span class=\"hljs-subst\">${fullResponse.length}<\/span> chars`<\/span>);<\/p>\n<p>    }<\/p>\n<p>    res.write(<span class=\"hljs-string\">`data: <span class=\"hljs-subst\">${<span class=\"hljs-built_in\">JSON<\/span>.stringify({ done: <span class=\"hljs-literal\">true<\/span> })}<\/span>nn`<\/span>);<\/p>\n<p>    res.end();<\/p>\n<p>  } <span class=\"hljs-keyword\">catch<\/span> (err) {<\/p>\n<p>    <span class=\"hljs-keyword\">if<\/span> (err.name === <span class=\"hljs-string\">&#8216;AbortError&#8217;<\/span> || err.name === <span class=\"hljs-string\">&#8216;APIUserAbortError&#8217;<\/span> || err.message === <span class=\"hljs-string\">&#8216;Request was aborted.&#8217;<\/span>) {<\/p>\n<p>      res.end();<\/p>\n<p>      <span class=\"hljs-keyword\">return<\/span>;<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-built_in\">console<\/span>.error(<span class=\"hljs-string\">&#8216;Stream error:&#8217;<\/span>, err.message);<\/p>\n<p>    res.write(<span class=\"hljs-string\">`data: <span class=\"hljs-subst\">${<span class=\"hljs-built_in\">JSON<\/span>.stringify({ error: err.message })}<\/span>nn`<\/span>);<\/p>\n<p>    res.end();<\/p>\n<p>  }<\/p>\n<p>});<\/p>\n<p><span class=\"hljs-keyword\">const<\/span> PORT = process.env.PORT || <span class=\"hljs-number\">3000<\/span>;<\/p>\n<p>app.listen(PORT, <span class=\"hljs-function\">() =&gt;<\/span> <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">`Server running at http:\/\/localhost:<span class=\"hljs-subst\">${PORT}<\/span>`<\/span>));<\/p>\n<h2>Building the Frontend<\/h2>\n<p>The client has two jobs: manage the session lifecycle and render the stream. The session lifecycle is simple (create on load, delete on &#171;New Chat&#187;), and the stream rendering is where most implementations get tripped up.<\/p>\n<p><span class=\"hljs-meta\">&lt;!DOCTYPE <span class=\"hljs-meta-keyword\">html<\/span>&gt;<\/span><\/p>\n<p><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">html<\/span> <span class=\"hljs-attr\">lang<\/span>=<span class=\"hljs-string\">&#171;en&#187;<\/span>&gt;<\/span><\/p>\n<p><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">head<\/span>&gt;<\/span><\/p>\n<p>  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">meta<\/span> <span class=\"hljs-attr\">charset<\/span>=<span class=\"hljs-string\">&#171;UTF-8&#187;<\/span>&gt;<\/span><\/p>\n<p>  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">meta<\/span> <span class=\"hljs-attr\">name<\/span>=<span class=\"hljs-string\">&#171;viewport&#187;<\/span> <span class=\"hljs-attr\">content<\/span>=<span class=\"hljs-string\">&#171;width=device-width, initial-scale=1.0&#187;<\/span>&gt;<\/span><\/p>\n<p>  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">title<\/span>&gt;<\/span>AI Streaming Chat<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">title<\/span>&gt;<\/span><\/p>\n<p>  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">style<\/span>&gt;<\/span><span class=\"css\"><\/span><\/p>\n<p>    * { <span class=\"hljs-attribute\">box-sizing<\/span>: border-box; <span class=\"hljs-attribute\">margin<\/span>: <span class=\"hljs-number\">0<\/span>; <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">0<\/span>; }<\/p>\n<p>    <span class=\"hljs-selector-tag\">body<\/span> {<\/p>\n<p>      <span class=\"hljs-attribute\">font-family<\/span>: -apple-system, BlinkMacSystemFont, <span class=\"hljs-string\">&#8216;Segoe UI&#8217;<\/span>, Roboto, sans-serif;<\/p>\n<p>      <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-number\">#f0f2f5<\/span>;<\/p>\n<p>      <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-number\">100vh<\/span>;<\/p>\n<p>      <span class=\"hljs-attribute\">display<\/span>: flex;<\/p>\n<p>      <span class=\"hljs-attribute\">flex-direction<\/span>: column;<\/p>\n<p>      <span class=\"hljs-attribute\">align-items<\/span>: center;<\/p>\n<p>      <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">1.5rem<\/span> <span class=\"hljs-number\">1rem<\/span>;<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-selector-class\">.chat-wrapper<\/span> { <span class=\"hljs-attribute\">width<\/span>: <span class=\"hljs-number\">100%<\/span>; <span class=\"hljs-attribute\">max-width<\/span>: <span class=\"hljs-number\">700px<\/span>; <span class=\"hljs-attribute\">display<\/span>: flex; <span class=\"hljs-attribute\">flex-direction<\/span>: column; <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-number\">100%<\/span>; }<\/p>\n<p>    <span class=\"hljs-selector-tag\">h1<\/span> { <span class=\"hljs-attribute\">font-size<\/span>: <span class=\"hljs-number\">1.2rem<\/span>; <span class=\"hljs-attribute\">color<\/span>: <span class=\"hljs-number\">#333<\/span>; <span class=\"hljs-attribute\">margin-bottom<\/span>: <span class=\"hljs-number\">1rem<\/span>; }<\/p>\n<p>    <span class=\"hljs-selector-id\">#messages<\/span> {<\/p>\n<p>      <span class=\"hljs-attribute\">flex<\/span>: <span class=\"hljs-number\">1<\/span>; <span class=\"hljs-attribute\">overflow-y<\/span>: auto; <span class=\"hljs-attribute\">background<\/span>: white; <span class=\"hljs-attribute\">border<\/span>: <span class=\"hljs-number\">1px<\/span> solid <span class=\"hljs-number\">#e0e0e0<\/span>;<\/p>\n<p>      <span class=\"hljs-attribute\">border-radius<\/span>: <span class=\"hljs-number\">10px<\/span>; <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">1rem<\/span>; <span class=\"hljs-attribute\">display<\/span>: flex; <span class=\"hljs-attribute\">flex-direction<\/span>: column; gap: <span class=\"hljs-number\">0.75rem<\/span>;<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-selector-class\">.message<\/span> {<\/p>\n<p>      <span class=\"hljs-attribute\">max-width<\/span>: <span class=\"hljs-number\">85%<\/span>; <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">0.65rem<\/span> <span class=\"hljs-number\">0.9rem<\/span>; <span class=\"hljs-attribute\">border-radius<\/span>: <span class=\"hljs-number\">10px<\/span>;<\/p>\n<p>      <span class=\"hljs-attribute\">line-height<\/span>: <span class=\"hljs-number\">1.6<\/span>; <span class=\"hljs-attribute\">font-size<\/span>: <span class=\"hljs-number\">0.93rem<\/span>; <span class=\"hljs-attribute\">white-space<\/span>: pre-wrap; <span class=\"hljs-attribute\">word-break<\/span>: break-word;<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-selector-class\">.user<\/span>      { <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-number\">#0070f3<\/span>; <span class=\"hljs-attribute\">color<\/span>: white; <span class=\"hljs-attribute\">align-self<\/span>: flex-end; }<\/p>\n<p>    <span class=\"hljs-selector-class\">.assistant<\/span> { <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-number\">#f1f1f1<\/span>; <span class=\"hljs-attribute\">color<\/span>: <span class=\"hljs-number\">#111<\/span>; <span class=\"hljs-attribute\">align-self<\/span>: flex-start; }<\/p>\n<p>    <span class=\"hljs-selector-class\">.input-row<\/span> { <span class=\"hljs-attribute\">display<\/span>: flex; gap: <span class=\"hljs-number\">0.5rem<\/span>; <span class=\"hljs-attribute\">margin-top<\/span>: <span class=\"hljs-number\">0.75rem<\/span>; <span class=\"hljs-attribute\">align-items<\/span>: flex-end; }<\/p>\n<p>    <span class=\"hljs-selector-tag\">textarea<\/span> {<\/p>\n<p>      <span class=\"hljs-attribute\">flex<\/span>: <span class=\"hljs-number\">1<\/span>; <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">0.7rem<\/span>; <span class=\"hljs-attribute\">border<\/span>: <span class=\"hljs-number\">1px<\/span> solid <span class=\"hljs-number\">#ddd<\/span>; <span class=\"hljs-attribute\">border-radius<\/span>: <span class=\"hljs-number\">8px<\/span>;<\/p>\n<p>      <span class=\"hljs-attribute\">font-size<\/span>: <span class=\"hljs-number\">0.93rem<\/span>; <span class=\"hljs-attribute\">font-family<\/span>: inherit; <span class=\"hljs-attribute\">resize<\/span>: none; <span class=\"hljs-attribute\">height<\/span>: <span class=\"hljs-number\">52px<\/span>; <span class=\"hljs-attribute\">overflow-y<\/span>: auto;<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-selector-tag\">button<\/span> { <span class=\"hljs-attribute\">padding<\/span>: <span class=\"hljs-number\">0.65rem<\/span> <span class=\"hljs-number\">1.2rem<\/span>; <span class=\"hljs-attribute\">border<\/span>: none; <span class=\"hljs-attribute\">border-radius<\/span>: <span class=\"hljs-number\">8px<\/span>; <span class=\"hljs-attribute\">font-size<\/span>: <span class=\"hljs-number\">0.93rem<\/span>; <span class=\"hljs-attribute\">cursor<\/span>: pointer; }<\/p>\n<p>    <span class=\"hljs-selector-id\">#send<\/span>          { <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-number\">#0070f3<\/span>; <span class=\"hljs-attribute\">color<\/span>: white; }<\/p>\n<p>    <span class=\"hljs-selector-id\">#send<\/span><span class=\"hljs-selector-pseudo\">:disabled<\/span> { <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-number\">#aaa<\/span>; <span class=\"hljs-attribute\">cursor<\/span>: not-allowed; }<\/p>\n<p>    <span class=\"hljs-selector-id\">#clear<\/span>         { <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-number\">#f1f1f1<\/span>; <span class=\"hljs-attribute\">color<\/span>: <span class=\"hljs-number\">#555<\/span>; }<\/p>\n<p>    <span class=\"hljs-selector-class\">.status<\/span>        { <span class=\"hljs-attribute\">font-size<\/span>: <span class=\"hljs-number\">0.78rem<\/span>; <span class=\"hljs-attribute\">color<\/span>: <span class=\"hljs-number\">#aaa<\/span>; <span class=\"hljs-attribute\">margin-top<\/span>: <span class=\"hljs-number\">0.3rem<\/span>; <span class=\"hljs-attribute\">min-height<\/span>: <span class=\"hljs-number\">1rem<\/span>; }<\/p>\n<p>  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">style<\/span>&gt;<\/span><\/p>\n<p><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">head<\/span>&gt;<\/span><\/p>\n<p><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">body<\/span>&gt;<\/span><\/p>\n<p>  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">&#171;chat-wrapper&#187;<\/span>&gt;<\/span><\/p>\n<p>    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">h1<\/span>&gt;<\/span>AI Chat<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">h1<\/span>&gt;<\/span><\/p>\n<p>    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">&#171;messages&#187;<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span><\/p>\n<p>    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">p<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">&#171;status&#187;<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">&#171;status&#187;<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">p<\/span>&gt;<\/span><\/p>\n<p>    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">class<\/span>=<span class=\"hljs-string\">&#171;input-row&#187;<\/span>&gt;<\/span><\/p>\n<p>      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">textarea<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">&#171;input&#187;<\/span> <span class=\"hljs-attr\">placeholder<\/span>=<span class=\"hljs-string\">&#171;Ask something&#8230; (Ctrl+Enter to send)&#187;<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">textarea<\/span>&gt;<\/span><\/p>\n<p>      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">&#171;send&#187;<\/span>&gt;<\/span>Send<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span><\/p>\n<p>      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">button<\/span> <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">&#171;clear&#187;<\/span>&gt;<\/span>New Chat<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">button<\/span>&gt;<\/span><\/p>\n<p>    <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span><\/p>\n<p>  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span><\/p>\n<p>  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">script<\/span>&gt;<\/span><span class=\"javascript\"><\/span><\/p>\n<p>    <span class=\"hljs-keyword\">let<\/span> sessionId = <span class=\"hljs-literal\">null<\/span>;<\/p>\n<p>    <span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">initSession<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> res  = <span class=\"hljs-keyword\">await<\/span> fetch(<span class=\"hljs-string\">&#8216;\/session&#8217;<\/span>, { <span class=\"hljs-attr\">method<\/span>: <span class=\"hljs-string\">&#8216;POST&#8217;<\/span> });<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> data = <span class=\"hljs-keyword\">await<\/span> res.json();<\/p>\n<p>      sessionId  = data.sessionId;<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">appendMessage<\/span>(<span class=\"hljs-params\">role, text<\/span>) <\/span>{<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> div = <span class=\"hljs-built_in\">document<\/span>.createElement(<span class=\"hljs-string\">&#8216;div&#8217;<\/span>);<\/p>\n<p>      div.className = <span class=\"hljs-string\">`message <span class=\"hljs-subst\">${role}<\/span>`<\/span>;<\/p>\n<p>      div.textContent = text;<\/p>\n<p>      <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;messages&#8217;<\/span>).appendChild(div);<\/p>\n<p>      div.scrollIntoView({ <span class=\"hljs-attr\">behavior<\/span>: <span class=\"hljs-string\">&#8216;smooth&#8217;<\/span> });<\/p>\n<p>      <span class=\"hljs-keyword\">return<\/span> div;<\/p>\n<p>    }<\/p>\n<p>    <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;send&#8217;<\/span>).addEventListener(<span class=\"hljs-string\">&#8216;click&#8217;<\/span>, <span class=\"hljs-keyword\">async<\/span> () =&gt; {<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> input   = <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;input&#8217;<\/span>);<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> message = input.value.trim();<\/p>\n<p>      <span class=\"hljs-keyword\">if<\/span> (!message || !sessionId) <span class=\"hljs-keyword\">return<\/span>;<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> sendBtn = <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;send&#8217;<\/span>);<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> status  = <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;status&#8217;<\/span>);<\/p>\n<p>      input.value      = <span class=\"hljs-string\">&#187;<\/span>;<\/p>\n<p>      sendBtn.disabled = <span class=\"hljs-literal\">true<\/span>;<\/p>\n<p>      status.textContent = <span class=\"hljs-string\">&#8216;Generating&#8230;&#8217;<\/span>;<\/p>\n<p>      appendMessage(<span class=\"hljs-string\">&#8216;user&#8217;<\/span>, message);<\/p>\n<p>      <span class=\"hljs-keyword\">const<\/span> assistantBubble = appendMessage(<span class=\"hljs-string\">&#8216;assistant&#8217;<\/span>, <span class=\"hljs-string\">&#187;<\/span>);<\/p>\n<p>      <span class=\"hljs-keyword\">try<\/span> {<\/p>\n<p>        <span class=\"hljs-keyword\">const<\/span> response = <span class=\"hljs-keyword\">await<\/span> fetch(<span class=\"hljs-string\">&#8216;\/chat&#8217;<\/span>, {<\/p>\n<p>          <span class=\"hljs-attr\">method<\/span>: <span class=\"hljs-string\">&#8216;POST&#8217;<\/span>,<\/p>\n<p>          <span class=\"hljs-attr\">headers<\/span>: { <span class=\"hljs-string\">&#8216;Content-Type&#8217;<\/span>: <span class=\"hljs-string\">&#8216;application\/json&#8217;<\/span> },<\/p>\n<p>          <span class=\"hljs-attr\">body<\/span>: <span class=\"hljs-built_in\">JSON<\/span>.stringify({ message, sessionId }),<\/p>\n<p>        });<\/p>\n<p>        <span class=\"hljs-keyword\">if<\/span> (!response.ok) {<\/p>\n<p>          <span class=\"hljs-keyword\">const<\/span> err = <span class=\"hljs-keyword\">await<\/span> response.json();<\/p>\n<p>          assistantBubble.textContent = <span class=\"hljs-string\">`Error: <span class=\"hljs-subst\">${err.error}<\/span>`<\/span>;<\/p>\n<p>          <span class=\"hljs-keyword\">return<\/span>;<\/p>\n<p>        }<\/p>\n<p>        <span class=\"hljs-keyword\">const<\/span> reader  = response.body.getReader();<\/p>\n<p>        <span class=\"hljs-keyword\">const<\/span> decoder = <span class=\"hljs-keyword\">new<\/span> TextDecoder();<\/p>\n<p>        <span class=\"hljs-keyword\">while<\/span> (<span class=\"hljs-literal\">true<\/span>) {<\/p>\n<p>          <span class=\"hljs-keyword\">const<\/span> { value, done } = <span class=\"hljs-keyword\">await<\/span> reader.read();<\/p>\n<p>          <span class=\"hljs-keyword\">if<\/span> (done) <span class=\"hljs-keyword\">break<\/span>;<\/p>\n<p>          <span class=\"hljs-keyword\">const<\/span> lines = decoder.decode(value, { <span class=\"hljs-attr\">stream<\/span>: <span class=\"hljs-literal\">true<\/span> }).split(<span class=\"hljs-string\">&#8216;\\n&#8217;<\/span>);<\/p>\n<p>          <span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">const<\/span> line <span class=\"hljs-keyword\">of<\/span> lines) {<\/p>\n<p>            <span class=\"hljs-keyword\">if<\/span> (!line.startsWith(<span class=\"hljs-string\">&#8216;data: &#8216;<\/span>)) <span class=\"hljs-keyword\">continue<\/span>;<\/p>\n<p>            <span class=\"hljs-keyword\">try<\/span> {<\/p>\n<p>              <span class=\"hljs-keyword\">const<\/span> data = <span class=\"hljs-built_in\">JSON<\/span>.parse(line.slice(<span class=\"hljs-number\">6<\/span>));<\/p>\n<p>              <span class=\"hljs-keyword\">if<\/span> (data.done)  <span class=\"hljs-keyword\">break<\/span>; <span class=\"hljs-comment\">\/\/ exits inner for loop; outer while loop ends on next read() \u2192 done: true<\/span><\/p>\n<p>              <span class=\"hljs-keyword\">if<\/span> (data.error) { assistantBubble.textContent += <span class=\"hljs-string\">`\\n[Error: <span class=\"hljs-subst\">${data.error}<\/span>]`<\/span>; <span class=\"hljs-keyword\">break<\/span>; }<\/p>\n<p>              <span class=\"hljs-keyword\">if<\/span> (data.token) {<\/p>\n<p>                assistantBubble.textContent += data.token;<\/p>\n<p>                assistantBubble.scrollIntoView({ <span class=\"hljs-attr\">behavior<\/span>: <span class=\"hljs-string\">&#8216;smooth&#8217;<\/span> });<\/p>\n<p>              }<\/p>\n<p>            } <span class=\"hljs-keyword\">catch<\/span> { <span class=\"hljs-comment\">\/* split chunk; wait for next read *\/<\/span> }<\/p>\n<p>          }<\/p>\n<p>        }<\/p>\n<p>        status.textContent = <span class=\"hljs-string\">&#187;<\/span>;<\/p>\n<p>      } <span class=\"hljs-keyword\">catch<\/span> (err) {<\/p>\n<p>        assistantBubble.textContent = <span class=\"hljs-string\">`Request failed: <span class=\"hljs-subst\">${err.message}<\/span>`<\/span>;<\/p>\n<p>      } <span class=\"hljs-keyword\">finally<\/span> {<\/p>\n<p>        sendBtn.disabled = <span class=\"hljs-literal\">false<\/span>;<\/p>\n<p>        status.textContent = <span class=\"hljs-string\">&#187;<\/span>;<\/p>\n<p>        input.focus();<\/p>\n<p>      }<\/p>\n<p>    });<\/p>\n<p>    <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;clear&#8217;<\/span>).addEventListener(<span class=\"hljs-string\">&#8216;click&#8217;<\/span>, <span class=\"hljs-keyword\">async<\/span> () =&gt; {<\/p>\n<p>      <span class=\"hljs-keyword\">if<\/span> (!sessionId) <span class=\"hljs-keyword\">return<\/span>;<\/p>\n<p>      <span class=\"hljs-keyword\">await<\/span> fetch(<span class=\"hljs-string\">`\/session\/<span class=\"hljs-subst\">${sessionId}<\/span>`<\/span>, { <span class=\"hljs-attr\">method<\/span>: <span class=\"hljs-string\">&#8216;DELETE&#8217;<\/span> });<\/p>\n<p>      <span class=\"hljs-keyword\">await<\/span> initSession();<\/p>\n<p>      <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;messages&#8217;<\/span>).innerHTML = <span class=\"hljs-string\">&#187;<\/span>;<\/p>\n<p>      <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;status&#8217;<\/span>).textContent = <span class=\"hljs-string\">&#187;<\/span>;<\/p>\n<p>    });<\/p>\n<p>    <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;input&#8217;<\/span>).addEventListener(<span class=\"hljs-string\">&#8216;keydown&#8217;<\/span>, <span class=\"hljs-function\">(<span class=\"hljs-params\">e<\/span>) =&gt;<\/span> {<\/p>\n<p>      <span class=\"hljs-keyword\">if<\/span> (e.key === <span class=\"hljs-string\">&#8216;Enter&#8217;<\/span> &amp;&amp; e.ctrlKey) <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">&#8216;send&#8217;<\/span>).click();<\/p>\n<p>    });<\/p>\n<p>    initSession();<\/p>\n<p>  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">script<\/span>&gt;<\/span><\/p>\n<p><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">body<\/span>&gt;<\/span><\/p>\n<p><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">html<\/span>&gt;<\/span><\/p>\n<p>The try\/catch inside the inner loop handles a real edge case: SSE events occasionally span two network reads. When that happens, JSON.parse throws on the incomplete fragment, and catching it so the next read can deliver the rest keeps the stream alive. Without the catch, the whole stream crashes on what is essentially normal network behavior.<\/p>\n<h2>Running It<\/h2>\n<p>npm run dev<\/p>\n<p>The terminal confirms the server started:<\/p>\n<p>Server running at http:\/\/localhost:3000<\/p>\n<p>Hit http:\/\/localhost:3000 in your browser and type something. The terminal should print:<\/p>\n<p>[\/session] created: 2dfb2586-4601-4781-932d-cb3dcca5d5a5<\/p>\n<p>[\/chat] received: {<\/p>\n<p>  message: <span class=\"hljs-string\">&#8216;Do you know about StackAbuse?&#8217;<\/span>,<\/p>\n<p>  sessionId: <span class=\"hljs-string\">&#8216;2dfb2586-4601-4781-932d-cb3dcca5d5a5&#8217;<\/span><\/p>\n<p>}<\/p>\n<p>[stream complete] 304 chars<\/p>\n<p>In the browser, you will see the response build out token by token, which means the streaming is working. The interface looks like this with a live response:<\/p>\n<p>Send a follow-up that references the first answer and the model will remember it. The session history is included in every request, so context carries across turns. Click &#171;New Chat&#187; and the server deletes the session and creates a fresh one. The model starts over with no memory of the previous conversation.<\/p>\n<p>To inspect the raw stream, open DevTools, go to Network, find the \/chat request, and look at the EventStream tab. You will see each data: event arrive individually, which is the most useful thing I know of for debugging streaming behavior that looks correct in the UI but is not working the way you think.<\/p>\n<h2>Production Gaps to Address Before Launch<\/h2>\n<p>Before you ship this, know what the implementation does not handle. I have seen developers take tutorial code and deploy it unchanged.<\/p>\n<p><strong>Context overflow.<\/strong> Every request sends the full session history. gpt-4o-mini has a 128,000-token context window, which is large enough that most chat sessions will never hit it. But a session running for hours will eventually approach the limit. When it does, the API returns a context_length_exceeded error. The standard fix is a sliding window: keep the system prompt, trim old messages from the front of the history, optionally run a summarization call to compress old context before it falls off. This is a real engineering problem and there is no one-size answer.<\/p>\n<p><strong>Rate limiting.<\/strong> There is none here, so one client can exhaust your monthly OpenAI quota. Add express-rate-limit before this goes anywhere near the internet.<\/p>\n<p><strong>Authentication.<\/strong> The \/chat endpoint accepts requests from anyone. In a real deployment, you need session-based auth or JWT validation before the route handler runs. Without it, anyone who knows your URL is spending your API credits.<\/p>\n<p><strong>Multi-process sessions.<\/strong> Covered above. Redis or equivalent before you scale horizontally.<\/p>\n<h2>Conclusion<\/h2>\n<p>I have shipped versions of this exact setup to production twice now, across different companies and products, with the same core pattern: session store on the server, SSE for transport, and AbortController to stop wasting money on disconnected clients. The gaps I listed are not hypothetical warnings; I hit most of them myself before figuring out the fixes.<\/p>\n<p>The part that trips most developers is not the OpenAI API itself, but the plumbing around it: getting tokens to the browser the moment they arrive, keeping the connection alive without burning credits on disconnected clients, and maintaining conversation context without letting it grow unbounded. That is where the real work sits. Hopefully this article saved you the afternoon I spent learning most of it the hard way.<\/p>\n<p>The full code is at <a href=\"https:\/\/github.com\/ziaongit\/nodejs-openai-streaming\">github.com\/ziaongit\/nodejs-openai-streaming<\/a>. Clone it, break it, and make it your own.<\/p>","protected":false},"excerpt":{"rendered":"<p>I almost shipped a bad AI integration once. The feature worked technically: the model answered questions correctly, the UI looked clean, and everyone nodded along in our internal demo. Then we put real users on it, and within 48 hours a product manager Slack&#8217;d me asking why 60% of sessions &#8230; <\/p>\n<div><a class=\"more-link bs-book_btn\" href=\"https:\/\/imcodinggenius.com\/?p=899\">Read More<\/a><\/div>\n","protected":false},"author":0,"featured_media":900,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-899","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-news"],"_links":{"self":[{"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=\/wp\/v2\/posts\/899"}],"collection":[{"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=899"}],"version-history":[{"count":0,"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=\/wp\/v2\/posts\/899\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=\/wp\/v2\/media\/900"}],"wp:attachment":[{"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=899"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=899"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/imcodinggenius.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=899"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}