{"slug": "streaming-claude-tokens-from-bedrock-with-node-js-22-sse-made-simple", "title": "Streaming Claude Tokens from Bedrock with Node.js 22: SSE Made Simple", "summary": "A developer has published a guide showing how to stream Claude tokens from Amazon Bedrock to the browser using Node.js 22's native fetch API and Server-Sent Events (SSE). The approach wires a Lambda-backed SSE endpoint that pushes tokens to the client as they are generated, avoiding the perceived latency of waiting for a full completion. The guide emphasizes setting the stream flag to true in the Bedrock request payload and reusing the BedrockRuntimeClient across Lambda invocations.", "body_md": "Developers keep asking why LLM responses feel sluggish when they have to wait for the whole answer. With Bedrock’s streaming mode and Node 22’s native fetch, you can push tokens to the browser the moment they’re generated. This guide shows you how to wire a Lambda‑backed SSE endpoint in minutes.\n\nWhen you ask a large language model (LLM) like Claude a question, the model doesn’t write the whole paragraph in one go. It creates **tokens**—tiny pieces of text such as a word or punctuation—one after another. Think of a token stream like a faucet: water (tokens) drips out continuously instead of a bucket dumping all at once.  \n\nIf your application waits for the bucket, the user sees a blank screen for a few seconds, then a sudden flash of the complete answer. That pause hurts perceived performance, especially in chat‑like interfaces where users expect an immediate “typing…” indicator.\n\nStreaming lets you:\n\n**In plain English:** Streaming is like watching a movie as it’s filmed, rather than waiting for the whole film to be edited before the lights come up.  \n\nBedrock will only send a token‑by‑token stream when you set the `stream` flag to `true` in the request payload. Forgetting this flag makes the service behave like a classic HTTP request, returning the entire completion in a single JSON object. The downstream Server‑Sent Events (SSE) logic then never sees any incremental data, and the browser sits idle.  \n\nBefore you can ask Claude for tokens, you need a **client**—a small piece of code that knows how to talk to the Bedrock Runtime API. The official AWS SDK for JavaScript provides `@aws-sdk/client-bedrock-runtime`. Installing it is straightforward:\n\n```\nnpm install @aws-sdk/client-bedrock-runtime\n```\n\nThe SDK handles signing the request with your AWS credentials, retrying transient failures, and exposing a clean TypeScript interface. It saves you from manually crafting the Authorization header for every call.\n\n``` js\n// src/bedrockClient.ts\nimport { BedrockRuntimeClient } from \"@aws-sdk/client-bedrock-runtime\";\n\n/**\n * Create a Bedrock client that will be reused across Lambda invocations.\n * Re‑using the client avoids the overhead of creating a new HTTP connection each time.\n */\nexport const bedrockClient = new BedrockRuntimeClient({\n  // Region where your Bedrock model lives. Change if you deployed to a different region.\n  region: \"us-east-1\",\n  // Optional: increase timeout if you expect long generations.\n  requestHandler: undefined, // default NodeHttpHandler is fine for most cases\n});\n```\n\n**Tip:** Keep the client in a module‑level variable (outside the handler) so that AWS re‑uses the underlying TCP socket across cold‑starts.  \n\n`ThrottlingException`).\nNode 22 ships with the `fetch` API built‑in, just like browsers. This means you can call Bedrock with a streaming request without pulling in a third‑party HTTP library.  \n\n`ReadableStream` objects, which work nicely with the SSE format we’ll send to the browser.\nThe body must be a JSON string that tells Bedrock which model to use, what prompt to send, and that we want a stream:\n\n```\n{\n  \"modelId\": \"anthropic.claude-3-5-sonnet-20240620-v1:0\",\n  \"prompt\": \"Explain why streaming matters in a chat UI.\",\n  \"maxTokens\": 512,\n  \"temperature\": 0.7,\n  \"stream\": true\n}\n```\n\n**In plain English:** `stream: true` is the “turn on the faucet” switch.  \n\n``` js\n// src/handler.ts\nimport { bedrockClient } from \"./bedrockClient\";\nimport { InvokeModelCommand } from \"@aws-sdk/client-bedrock-runtime\";\n\n/**\n * Calls Claude via Bedrock using Node's native fetch.\n * Returns a ReadableStream of raw bytes that we will transform to SSE later.\n */\nasync function callClaudeStream(prompt: string): Promise<ReadableStream<Uint8Array>> {\n  // Build the command payload exactly as the SDK expects.\n  const command = new InvokeModelCommand({\n    // The model identifier from the Bedrock console.\n    modelId: \"anthropic.claude-3-5-sonnet-20240620-v1:0\",\n    // Bedrock expects the body as a stringified JSON object.\n    body: JSON.stringify({\n      prompt,\n      maxTokens: 512,\n      temperature: 0.7,\n      // The crucial flag – without it we get a single JSON response.\n      stream: true,\n    }),\n    // Accept a streaming response (text/event-stream).\n    accept: \"application/json\",\n    contentType: \"application/json\",\n  });\n\n  // The SDK returns a promise that resolves to a response object.\n  const response = await bedrockClient.send(command);\n\n  // Bedrock streams the body as a Uint8Array inside response.body.\n  // We cast to ReadableStream for TypeScript clarity.\n  if (!response.body) {\n    throw new Error(\"Bedrock returned an empty body – something went wrong.\");\n  }\n  return response.body as ReadableStream<Uint8Array>;\n}\n```\n\n**Key takeaway:** Using `InvokeModelCommand` together with `stream: true` gives you a low‑level byte stream you can pipe straight to the browser.  \n\n`us-west-2` but Bedrock is in `us-east-1`, the round‑trip adds extra milliseconds per token. Deploy the Lambda in the same region when possible.\nServer‑Sent Events (SSE) is a simple HTTP‑based protocol that lets a server push text chunks to a browser over a single long‑lived connection. The browser receives each chunk as an `event`.  \n\n`EventSource` API, no extra JavaScript libraries needed.\nAWS Lambda can stream responses, but you must set the `Content-Type` header to `text/event-stream` **and** write to the response object using the `callbackWaitsForEmptyEventLoop = false` pattern (or the newer async iterator support).  \n\n``` js\n// src/lambdaHandler.ts\nimport { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from \"aws-lambda\";\nimport { callClaudeStream } from \"./handler\";\n\n/**\n * Lambda entry point wired to API Gateway (HTTP API).\n * It receives a JSON body { prompt: string } from the browser,\n * calls Bedrock, and streams each token back as SSE.\n */\nexport const streamHandler = async (\n  event: APIGatewayProxyEventV2\n): Promise<APIGatewayProxyResultV2> => {\n  // Enable early return – we’ll write to the response manually.\n  // This tells Lambda not to wait for the event loop to be empty.\n  (global as any).callbackWaitsForEmptyEventLoop = false;\n\n  // Parse the incoming prompt; fall back to a friendly default.\n  const body = event.body ? JSON.parse(event.body) : {};\n  const prompt = typeof body.prompt === \"string\" ? body.prompt : \"Hello, Claude!\";\n\n  // Prepare the HTTP response headers for SSE.\n  const headers = {\n    \"Content-Type\": \"text/event-stream; charset=utf-8\",\n    // Prevent caching so browsers always get fresh tokens.\n    \"Cache-Control\": \"no-cache, no-transform\",\n    // Keep the connection alive.\n    Connection: \"keep-alive\",\n  };\n\n  // The `response` object that API Gateway expects.\n  // We will fill its `body` with a placeholder; the real streaming\n  // happens by writing directly to the underlying stream.\n  const response: APIGatewayProxyResultV2 = {\n    statusCode: 200,\n    headers,\n    // Body must be a string when the Lambda finishes, but we will\n    // never finish the function until the client disconnects.\n    body: \"\",\n    // Tell API Gateway that we will stream the payload.\n    isBase64Encoded: false,\n  };\n\n  // Obtain the raw Bedrock stream.\n  const bedrockStream = await callClaudeStream(prompt);\n\n  // Create a Transform stream that converts Bedrock JSON lines\n  // into SSE formatted text: \"data: <token>\\n\\n\"\n  const encoder = new TextEncoder();\n\n  // Helper: format a token as an SSE message.\n  const formatSSE = (data: string) => `data: ${data}\\n\\n`;\n\n  // The `pipeTo` method returns a promise that resolves when the\n  // source stream ends or an error occurs.\n  const sseWriter = new WritableStream({\n    async write(chunk) {\n      // Bedrock sends JSON objects like: {\"type\":\"token\",\"text\":\"Hello\"}\n      const text = new TextDecoder().decode(chunk);\n      // Split on newlines because Bedrock streams a series of JSON lines.\n      const lines = text.split(\"\\n\").filter(Boolean);\n      for (const line of lines) {\n        try {\n          const obj = JSON.parse(line);\n          if (obj.type === \"token\") {\n            // Send only the token text to the browser.\n            const sse = formatSSE(obj.text);\n            // Write the SSE string to the Lambda HTTP response.\n            // `response.body` will be ignored; we push directly to the\n            // underlying socket via `callback`.\n            // @ts-ignore – the Lambda runtime provides `responseStream` in the context.\n            // In the real deployment you use `event.requestContext.http.path`\n            // with a streaming-enabled API Gateway integration.\n            // Here we illustrate the core idea.\n          }\n        } catch (e) {\n          // If parsing fails, ignore the line – it might be a heartbeat.\n        }\n      }\n    },\n\n    // When the client disconnects or the model finishes, close the stream.\n    close() {\n      console.log(\"SSE stream closed by client or model.\");\n    },\n\n    abort(err) {\n      console.error(\"SSE stream aborted:\", err);\n    },\n  });\n\n  // Pipe Bedrock → our formatter → Lambda response stream.\n  // In a real Lambda, you would get the raw response stream from the\n  // API Gateway integration (e.g., `event.stream`). Here we illustrate the pattern.\n  await bedrockStream.pipeTo(sseWriter);\n\n  // At this point Lambda will keep the connection open until the client aborts.\n  // Returning the response object satisfies the TypeScript signature.\n  return response;\n};\n```\n\n**Tip:** The most common mistake is forgetting to set `Content-Type: text/event-stream`. Without it the browser treats the payload as ordinary text and never fires `message` events.  \n\n`require`. Keep your function pure ESM or stick to CommonJS throughout.\n`Content-Type` to `text/event-stream`.\nA streaming pipeline is only as reliable as its weakest link. You need to anticipate three categories of problems:\n\nIf you keep writing to the response socket faster than the client can consume, the underlying TCP buffer fills up, and the Node process may be forced to pause or even crash with `ERR_STREAM_WRITE_AFTER_END`. Properly handling the `writableStream`’s `ready` promise prevents that.  \n\n```\n// Inside the WritableStream `write` method from the previous section\nasync write(chunk) {\n  // Decode and split as before...\n  const lines = new TextDecoder().decode(chunk).split(\"\\n\").filter(Boolean);\n  for (const line of lines) {\n    try {\n      const obj = JSON.parse(line);\n      if (obj.type === \"error\") {\n        // Bedrock can send an error object mid‑stream.\n        const sse = formatSSE(`ERROR: ${obj.message}`);\n        // Push the error to the client and then abort the stream.\n        await controller.enqueue(encoder.encode(sse));\n        controller.terminate(); // stop further processing\n        return;\n      }\n\n      if (obj.type === \"token\") {\n        const sse = formatSSE(obj.text);\n        // Back‑pressure: wait until the underlying stream is ready.\n        if (controller.desiredSize === 0) {\n          await controller.flush(); // pause until the client drains\n        }\n        await controller.enqueue(encoder.encode(sse));\n      }\n    } catch (parseErr) {\n      // If JSON is malformed, treat it as a non‑fatal heartbeat.\n      console.warn(\"Failed to parse Bedrock line:\", line);\n    }\n  }\n}\n```\n\n**In plain English:** If Bedrock tells us “I ran into an error”, we forward that error to the browser and stop sending more tokens.  \n\nAPI Gateway provides a `signal` (an `AbortSignal`) you can listen to:\n\n``` js\n// At the top of streamHandler\nconst abortSignal = (event.requestContext as any).http?.signal;\nif (abortSignal?.aborted) {\n  console.log(\"Client already disconnected – abort early.\");\n  return { statusCode: 204, headers, body: \"\" };\n}\n\n// Later, attach a listener\nabortSignal?.addEventListener(\"abort\", () => {\n  console.log(\"Client aborted the SSE connection.\");\n  // Close Bedrock stream to avoid paying for unused tokens.\n  // The SDK doesn't expose a direct abort, but you can\n  // use an AbortController when you create the command.\n});\n```\n\nWhen you create the `InvokeModelCommand`, pass an `AbortSignal` so the request can be cancelled:\n\n``` js\nimport { AbortController } from \"node-abort-controller\";\n\nconst abortCtrl = new AbortController();\nconst command = new InvokeModelCommand({\n  // …payload…\n  // Attach the signal for cancellation.\n  // @ts-ignore – the SDK expects `abortSignal` in the options bag.\n  abortSignal: abortCtrl.signal,\n});\n```\n\n**Key takeaway:** Tie the client’s abort signal to the Bedrock request; otherwise you may keep generating tokens that nobody sees.  \n\nIf Bedrock returns `ThrottlingException`, implement exponential back‑off before retrying:\n\n```\nasync function safeInvoke(command: InvokeModelCommand, retries = 3) {\n  for (let attempt = 0; attempt <= retries; attempt++) {\n    try {\n      return await bedrockClient.send(command);\n    } catch (err: any) {\n      if (err.name === \"ThrottlingException\" && attempt < retries) {\n        const delay = Math.pow(2, attempt) * 200; // 200ms, 400ms, 800ms...\n        console.warn(`Throttled – retrying in ${delay}ms`);\n        await new Promise((r) => setTimeout(r, delay));\n        continue;\n      }\n      throw err; // non‑throttle errors bubble up\n    }\n  }\n}\n```\n\n**What you now have in your toolbox:** \n\n`@aws-sdk/client-bedrock-runtime` is a few lines, but you must remember to set `stream: true`.\n`callbackWaitsForEmptyEventLoop = false`, and proper handling of the response stream.\nBy following the steps above, you can ship a chat UI that feels as responsive as a real‑time conversation, while staying within the cost and performance boundaries of AWS services. Happy streaming!\n\n**Transparency notice**\n\nThis article was written with the help of an AI system — [Groq](https://groq.com) (GPT OSS 120B).\n\n**Published:** 2026-09-18 · **Primary focus:** Bedrock\n\nAll code blocks are intended to be correct and runnable, but please verify them\n\nagainst the official docs for the tools mentioned before using in production.\n\n*Find an error? Drop a comment — corrections are always welcome.*", "url": "https://wpnews.pro/news/streaming-claude-tokens-from-bedrock-with-node-js-22-sse-made-simple", "canonical_source": "https://dev.to/dineshgowtham/streaming-claude-tokens-from-bedrock-with-nodejs-22-sse-made-simple-4oid", "published_at": "2026-09-18 15:05:44+00:00", "updated_at": "2026-09-18 15:22:58.928190+00:00", "lang": "en", "topics": ["large-language-models", "ai-infrastructure", "developer-tools", "ai-tools"], "entities": ["Amazon Bedrock", "Claude", "Node.js 22", "AWS Lambda", "Anthropic", "@aws-sdk/client-bedrock-runtime"], "alternates": {"html": "https://wpnews.pro/news/streaming-claude-tokens-from-bedrock-with-node-js-22-sse-made-simple", "markdown": "https://wpnews.pro/news/streaming-claude-tokens-from-bedrock-with-node-js-22-sse-made-simple.md", "text": "https://wpnews.pro/news/streaming-claude-tokens-from-bedrock-with-node-js-22-sse-made-simple.txt", "jsonld": "https://wpnews.pro/news/streaming-claude-tokens-from-bedrock-with-node-js-22-sse-made-simple.jsonld"}}