Tutorials

How to Stream AI API Responses in Node.js

AM
Alfian Majid
••10 min read
How to Stream AI API Responses in Node.js

Building real-time AI applications sounds simple enough until your server hits 500 concurrent requests. Suddenly, response times skyrocket, CPU usage spikes, and your Node.js event loop grinds to a total halt. I ran into this exact issue while stress-testing an internal code assistant running on Claude Sonnet 5. The server didn't crash from memory exhaustion; it died because synchronous string processing and unmanaged stream backpressure choked the event loop thread.

When you stream tokens from provider APIs like Anthropic or OpenAI, your backend acts as a pipe. If you process those incoming token chunks inefficiently, hold massive string buffers in memory, or block on sync operations inside your stream transformers, every other user on your application pays the price. Non-blocking response streaming isn't an luxury optimization. It is an absolute requirement for scalable AI microservices in 2026.

What You'll Learn

By following this tutorial, you will build a enterprise-ready token streaming pipeline using Node.js 24 and Express/Fastify patterns. Specifically, you will master:

  • Configuring native Server-Sent Events (SSE) with standard HTTP headers
  • Processing streaming tokens from Claude Sonnet 5 and GPT-5.6 Sol using Node async generators
  • Handling backpressure properly to prevent memory leaks when client connections slow down
  • Tying upstream API connection lifetimes directly to client connection states via AbortController
  • Debugging event loop lag under heavy load with custom monitoring hooks

Prerequisites & What You Need

Before jumping into the code, ensure your development environment satisfies these requirements:

  • Node.js v24.0.0+ installed (we rely on native Web Streams and enhanced event loop timers)
  • TypeScript 5.5+ or modern ES modules enabled in your package.json
  • An API key from OpenAI (for gpt-5.6-sol) or Anthropic (for claude-sonnet-5)
  • Basic familiarity with HTTP chunked transfer encoding and Express middleware
  • A local terminal with curl or Postman for testing SSE stream endpoints

Step-by-Step Guide

Step 1: Set Up standard SSE Headers

Server-Sent Events allow your server to push real-time text chunks over a single, long-lived HTTP connection. The most critical part of setting up SSE is telling reverse proxies (like Nginx, Cloudflare, or AWS ALB) not to buffer your tokens. If a proxy buffers your output, your user receives 500 tokens all at once after a 10-second delay, completely ruining the interactive experience.

Here is how you set up the initial response headers cleanly in Node.js:

import { Request, Response } from 'express';

export function initializeSSEResponse(res: Response): void {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no' // Disables Nginx response buffering
  });

  // Send an initial padding comment to flush proxy buffers immediately
  res.write(': sse handshake established\n\n');
}

Step 2: Connect Upstream Model Streams with Abort Signals

A frequent bug in production AI backends is orphaned model calls. When a user closes their browser tab mid-generation, your backend often keeps consuming tokens from OpenAI or Anthropic in the background. You pay for those tokens, and your event loop wastes CPU cycles parsing text nobody will ever read.

Developer Tip: Always pass an AbortSignal attached to the client connection's close event directly into the AI SDK calls. This stops upstream charges within milliseconds of a client drop.

import Anthropic from '@anthropic-ai/sdk';
import { Request, Response } from 'express';

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

export async function handleAIStream(req: Request, res: Response) {
  const abortController = new AbortController();

  // Trigger abort when user closes browser tab or drops socket
  req.on('close', () => {
    if (!res.writableEnded) {
      abortController.abort();
      console.log('Client disconnected. Aborted upstream Claude API call.');
    }
  });

  try {
    const stream = await anthropic.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 1024,
      messages: [{ role: 'user', content: req.body.prompt }],
      stream: true,
    }, { signal: abortController.signal });

    for await (const chunk of stream) {
      if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
        const payload = JSON.stringify({ text: chunk.delta.text });
        res.write(`data: ${payload}\n\n`);
      }
    }

    res.write('data: [DONE]\n\n');
    res.end();
  } catch (err: any) {
    if (err.name === 'AbortError') {
      console.log('Stream successfully aborted.');
    } else {
      console.error('Unhandled stream error:', err);
      if (!res.headersSent) {
        res.status(500).json({ error: 'Internal stream failure' });
      }
    }
  }
}

Step 3: Prevent Event Loop Starvation with Yielding

If you process markdown parsing, syntax highlighting, or regex sanitization on every incoming token chunk synchronously, your Node.js thread will lock up. Node processes event loop tasks in distinct phases. High-frequency network IO coupled with synchronous text computation halts timer callbacks and incoming network connections.

To avoid clogging the thread during long outputs, use setImmediate() inside heavy processing loops to break up macro-tasks:

async function* sanitizeTokenStream(rawStream: AsyncIterable<string>) {
  let counter = 0;
  for await (const chunk of rawStream) {
    // Process text
    const cleanChunk = chunk.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
    yield cleanChunk;

    // Relinquish control back to the event loop every 20 chunks
    counter++;
    if (counter % 20 === 0) {
      await new Promise((resolve) => setImmediate(resolve));
    }
  }
}

Real-World Example

Here is a complete, production-grade Express server implementation streaming tokens from GPT-5.6 Sol. It handles connection cleanups, stream aborts, SSE framing, and graceful shutdown signal handling.

import express from 'express';
import OpenAI from 'openai';

const app = express();
app.use(express.json());

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

app.post('/api/generate', async (req, res) => {
  const { prompt } = req.body;

  if (!prompt) {
    return res.status(400).json({ error: 'Missing prompt parameter' });
  }

  // 1. Establish SSE headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  const controller = new AbortController();

  // 2. Clean disconnect listener
  req.on('close', () => {
    controller.abort();
  });

  try {
    // 3. Initiate API stream with latest 2026 GPT model
    const responseStream = await openai.chat.completions.create({
      model: 'gpt-5.6-sol',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    }, { signal: controller.signal });

    // 4. Pump stream data to response pipe
    for await (const chunk of responseStream) {
      const content = chunk.choices[0]?.delta?.content || '';
      if (content) {
        // SSE format requires double newlines at the end of each event
        res.write(`data: ${JSON.stringify({ token: content })}\n\n`);
      }
    }

    // 5. Send completion frame
    res.write('data: [DONE]\n\n');
    res.end();
  } catch (error: any) {
    if (error.name === 'AbortError') {
      console.log('Request aborted by client disconnect.');
    } else {
      console.error('API Error:', error.message);
      res.write(`data: ${JSON.stringify({ error: 'Stream interrupted' })}\n\n`);
      res.end();
    }
  }
});

const server = app.listen(3000, () => {
  console.log('Non-blocking AI stream server running on port 3000');
});

Common Mistakes & Troubleshooting

Even senior engineers hit subtle pitfalls when combining HTTP streams with Node event handlers. Here are the four most frequent errors and exact fixes for each.

  • Error: ERR_HTTP_HEADERS_SENT
    Cause: Your code attempted to send a res.status(500).json() error block after the response had already been initialized with res.writeHead(200).
    Fix: Always check res.headersSent before attempting to output standard JSON error responses in your try-catch blocks.
  • Error: ERR_STREAM_PREMATURE_CLOSE
    Cause: The client closed the connection while an internal Node pipeline stream wrapper was mid-operation.
    Fix: Attach explicit error handling callbacks to stream piping mechanisms using stream.finished() or proper async iterator try-catch wrappers.
  • Error: JavaScript heap out of memory
    Cause: Storing the entire cumulative response string in a global or request-scoped variable for logging purposes while streaming 10,000+ token generations under high concurrency.
    Fix: Avoid storing full stream responses in local variables inside request handlers. If you must log responses, stream them directly to an external write-stream or analytics queue worker.
  • Issue: Token Buffering on Reverse Proxies
    Cause: Cloudflare or Gunicorn/Nginx buffers responses until a specific byte threshold (e.g. 4KB) is reached before flushing to client.
    Fix: Send header X-Accel-Buffering: no and configure proxy buffer sizes explicitly to zero for SSE paths.

Why Choose Server-Sent Events Over WebSockets?

When engineering non-blocking token outputs for AI applications, developers often wonder whether to pick WebSockets or Server-Sent Events (SSE). For 90% of generative AI applications, SSE is the superior engineering choice.

WebSockets are full-duplex, meaning both client and server send messages back and forth over a custom persistent protocol (`ws://` or `wss://`). While necessary for interactive multiplayer gaming or real-time collaborative whiteboards, WebSockets introduce unnecessary complexity for simple text streaming:

  • WebSockets bypass standard HTTP load balancing, requiring custom sticky session architectures or complex socket routers.
  • WebSockets fail to work out-of-the-box with standard HTTP enterprise security layers, WAF rules, and CORS patterns without additional configuration.
  • HTTP/2 and HTTP/3 render WebSocket connection multiplexing advantages obsolete. Over HTTP/2, SSE shares a single TCP connection cleanly alongside normal HTTP fetches.
  • Browser implementation of SSE via the native EventSource interface automatically handles reconnection logic and message ID tracking with zero external dependencies.

Architecture Rule: Use WebSockets only when the client must continuously push high-frequency binary data to the server while receiving responses simultaneously. If the client sends one prompt and receives a stream of text tokens back, use Server-Sent Events.

How Do You Debug High-Concurrency Memory Leaks?

Memory leaks during AI streaming rarely show up during local testing. They emerge under concurrent load testing when hundreds of open sockets retain references to event listeners or stream contexts.

Here is my exact methodology for pinpointing stream memory leaks in Node.js backends:

1. Track Node Event Loop Lag

Use Node's native perf_hooks module to monitor how long the event loop gets delayed by synchronous stream processing tasks:

import { monitorEventLoopDelay } from 'node:perf_hooks';

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  console.log(`Event Loop Delay (p99): ${histogram.percentile(99) / 1e6} ms`);
  histogram.reset();
}, 5000);

If your p99 delay exceeds 50ms, your stream parsing, JSON serialization, or string processing logic is executing synchronously on the main thread for too long.

2. Catch Dangling Event Listeners

When you attach listeners like req.on('close', ...) inside request handlers without properly unwinding them when streams terminate normally, Node outputs a warning:

(node:12345) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 close listeners added to [ServerResponse].

Always detach event listeners when the stream ends naturally by using abortController.signal.addEventListener('abort', fn, { once: true }) or explicitly calling req.removeListener() in a finally block.

Pro Tips & Advanced Usage

Once your standard SSE baseline is operational, apply these advanced optimization techniques to maximize throughput and reliability under heavy traffic.

  • Implement Stream Backpressure Controls: Check the return value of res.write(). If res.write() returns false, Node's internal buffer is full. Stop reading from the AI stream until the drain event fires on the response object.
  • Structured SSE Payloads: Send JSON objects instead of raw strings over SSE channels. Include metadata like current token counts, token generation speeds (tokens/sec), and stop reasons for client UI rendering.
  • Multi-Agent Multiplexing: When orchestrating multi-agent systems via agent frameworks like Swarm or LangGraph, assign an agent_id field inside your streaming SSE events so your client UI knows which agent is typing in real-time.
  • Graceful Server Shutdowns: When deploying code updates, close the listening HTTP server first, then push a custom termination SSE frame (e.g. event: server_shutdown) to connected clients so browser apps reconnect to new backend instances automatically.

Production Warning: Always configure timeout settings explicitly. Set req.socket.setTimeout(0) on streaming routes so Node doesn't kill long-running LLM outputs after default socket idle timeouts.

Now that your Node.js application streams tokens efficiently without blocking the event loop, take your infrastructure to the next level with these hands-on steps:

  • Test your endpoint throughput under load using Autocannon: npx autocannon -c 100 -d 30 -m POST http://localhost:3000/api/generate
  • Add real-time client UI rendering using modern Web Streams parsing utilities in React or Vue
  • Explore streaming protocol integrations with Model Context Protocol (MCP) tools to allow AI agents to invoke external commands asynchronously
  • Set up distributed Redis Pub/Sub backbones to coordinate token streams across auto-scaling Kubernetes backend pods

Share this article

About the Author

Alfian Majid

Alfian Majid

Founder & Editor-in-Chief

Solo developer and blogger from Indonesia. Runs CogitoDaily as a passion project - covering AI news, testing tools, and writing guides. Background in web development and game tech. When not writing about AI, you'll find me deep in anime or gaming.