n4nAI

Streaming LLM responses from Cloudflare Workers

Learn how to build a Cloudflare Worker that streams LLM responses over SSE, with runnable code, error handling, and debugging tips for production.

n4n Team2 min read517 words

Audio narration

Coming soon — every post will get a voice note here.

Streaming LLM responses from Cloudflare Workers cuts time-to-first-token for chat UIs and avoids buffering entire completions in worker memory. This tutorial builds a minimal Worker that proxies an OpenAI-compatible chat endpoint and forwards Server-Sent Events to the browser. You’ll get runnable code, expected outputs, and the failure modes that bite in production.

Prerequisites

  • Node.js 18+ and Wrangler v3 (npm i -g wrangler)
  • An API key for an OpenAI-compatible inference service (OpenAI, or a gateway like n4n.ai that exposes one endpoint for 240+ models)
  • Basic familiarity with TypeScript and fetch streams

Scaffold the Worker

Run:

wrangler init streaming-worker --type hello-world
cd streaming-worker

Replace src/index.ts with a skeleton that echoes a headers check:

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    return new Response("ok");
  }
};

Publish a quick check:

wrangler dev
curl http://localhost:8787
# expected: ok

Call the LLM with streaming enabled

OpenAI’s chat completions endpoint accepts stream: true and returns newline-delimited data: JSON chunks. The Worker must forward the body without buffering. Use the environment binding for the key.

interface Env {
  LLM_API_KEY: string;
  LLM_BASE_URL?: string;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const base = env.LLM_BASE_URL ?? "https://api.openai.com/v1";
    const upstream = await fetch(`${base}/chat/completions`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${env.LLM_API_KEY}`,
      },
      body: JSON.stringify({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: "Say hello in 5 words." }],
        stream: true,
      }),
    });

    return new Response(upstream.body, {
      headers: {
        "content-type": "text/event-stream",
        "cache-control": "no-cache",
        connection: "keep-alive",
      },
    });
  }
};

This already streams LLM responses from Cloudflare Workers, but the browser receives OpenAI’s raw wire format, not clean SSE. We’ll adapt it.

Transform the upstream stream to clean SSE

Browsers expect event: and data: lines. OpenAI sends data: {json}\n\n with a final data: [DONE]. We can pipe through a TransformStream to rewrite each chunk into a standardized data: frame and strip the [DONE] sentinel.

function toSSE(upstream: Response): Response {
  const decoder = new TextDecoder();
  const encoder = new TextEncoder();
  const ts = new TransformStream({
    transform(chunk, controller) {
      const text = decoder.decode(chunk, { stream: true });
      for (const line of text.split("\n")) {
        const trimmed = line.trim();
        if (!trimmed.startsWith("data:")) continue;
        const payload = trimmed.slice(5).trim();
        if (payload === "[DONE]") {
          controller.enqueue(encoder.encode("event: done\ndata: [DONE]\n\n"));
          continue;
        }
        try {
          const json = JSON.parse(payload);
          const token = json.choices?.[0]?.delta?.content ?? "";
          if (token) controller.enqueue(encoder.encode(`data: ${token}\n\n`));
        } catch {
          // skip malformed keep-alives
        }
      }
    },
  });
  return new Response(upstream.body!.pipeThrough(ts), {
    headers: {
      "content-type": "text/event-stream",
      "cache-control": "no-cache",
      connection: "keep-alive",
    },
  });
}

The split-on-newline approach assumes chunks align to lines; in practice add a line buffer across transform calls. For a tutorial, the simplification is fine.

Wire it into the handler:

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const base = env.LLM_BASE_URL ?? "https://api.openai.com/v1";
    const upstream = await fetch(`${base}/chat/completions`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${env.LLM_API_KEY}`,
      },
      body: JSON.stringify({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: "Count to 3." }],
        stream: true,
      }),
    });
    if (!upstream.ok) {
      return new Response(`upstream error ${upstream.status}`, { status: 502 });
    }
    return toSSE(upstream);
  }
};

Run wrangler dev and hit it with curl:

curl -N http://localhost:8787

Expected output (tokens vary):

data: One

data: ,

data: two

data: ,

data: three

event: done
data: [DONE]

Consume the stream in the browser

Use the native EventSource only for GET. Since we POST, use fetch with a reader:

const res = await fetch("/api/stream", { method: "POST" });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value);
  for (const line of chunk.split("\n")) {
    if (line.startsWith("data: ")) {
      const text = line.slice(6);
      if (text === "[DONE]") continue;
      document.body.append(text);
    }
  }
}

Debugging streaming failures on the edge

Serverless Deployment Debugging for LLM Apps means watching where the pipe breaks. Three common issues:

Upstream timeout under cold start

Cloudflare Workers have a 30s CPU limit but no wall-clock limit for streaming. If the model takes 40s to first token, the connection stays open. However, a misconfigured fetch with cf: { timeout: 1000 } will abort. Don’t set aggressive timeouts on streaming requests.

Truncated responses from gzip

If you manually set accept-encoding: gzip and the upstream returns compressed chunks, the TextDecoder will produce garbage. Let fetch handle decompression by omitting that header. The TransformStream receives decoded text.

CORS and preflight

Browser POSTs from a different origin trigger OPTIONS. Add a preflight handler:

if (req.method === "OPTIONS") {
  return new Response(null, {
    headers: {
      "access-control-allow-origin": "*",
      "access-control-allow-methods": "POST",
      "access-control-allow-headers": "content-type",
    },
  });
}

Adding provider fallback without rewriting SSE

If you point the Worker at a single provider, a 429 kills the stream. An OpenAI-compatible gateway such as n4n.ai fronts 240+ models and automatically fails over when a provider is rate-limited or degraded, while preserving the same SSE shape. Swap LLM_BASE_URL to the gateway endpoint and keep the toSSE logic untouched. The gateway also forwards provider cache-control hints, so you can inspect upstream.headers.get('x-cache') if you need to verify caching.

Set in wrangler.toml:

[vars]
LLM_BASE_URL = "https://api.n4n.ai/v1"

No code change required in the transform.

Production hardening checklist

  • Stream the request body too if you accept long user input; don’t buffer it.
  • Use env.LLM_API_KEY from secrets, not vars: wrangler secret put LLM_API_KEY.
  • Log only request IDs, not token content, to avoid leaking PII in Workers observability.
  • Set content-type: text/event-stream; charset=utf-8 explicitly to avoid middleware sniffing.

Streaming LLM responses from Cloudflare Workers is straightforward once you treat the upstream body as an opaque stream and apply a thin transform. The patterns above ship.

Tagscloudflare-workersstreamingssetutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All serverless deployment debugging for llm apps posts →