◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-04-13Advanced

LLM Streaming in Rork Apps: Building ChatGPT-Style Real-Time AI Responses with Expo and SSE

Field notes on shipping LLM streaming (SSE) in React Native and Expo. Anthropic, OpenAI and Gemini behind one interface, AbortController cancellation and retries, a Cloudflare Workers proxy, context compaction, and a mock SSE server for testing at zero API cost.

streamingSSELLM3AI31Anthropic4OpenAI5React Native239Expo219Cloudflare Workers25Rork585

✦ Premium Article

Twelve seconds of a spinner. That was the first version of the AI chat in my app: tap send, watch a circle rotate, and by the time the answer finally landed the tester's thumb was already reaching for the home button. The model wasn't slow. The design was — waiting for the full generation before showing anything turns the entire wait into dead air.

LLM streaming, powered by Server-Sent Events (SSE), solves this at the source. Instead of waiting for the model to finish generating, tokens stream to the client as they're produced. Users see text flowing within milliseconds, and that psychological shift — from "waiting" to "watching it think" — has a measurable impact on retention.

The catch is that streaming in React Native requires a fundamentally different approach than the browser. EventSource doesn't exist in the RN runtime. Reading a ReadableStream from fetch requires specific handling. Wiring up AbortController correctly is trickier than it looks. This guide covers all of it, with production-tested code you can drop into your Rork project.

Why Streaming Is a UX Inflection Point

Before diving into implementation, it's worth understanding what's actually happening from a user perspective.

With blocking (non-streaming) responses, the pattern is always the same: send a message, sit through silence, receive a wall of text. Short answers survive it. The longer, more considered responses that make an LLM worth paying for do not — asking Claude Sonnet for a few hundred words routinely put me in the 15–20 second range.

You don't need analytics to see what that does. Watch someone use it. Around five seconds they glance away from the screen. Around ten they switch apps. When they come back they've forgotten what they asked. The conversation stops before any retention metric has a chance to record it.

Streaming collapses this experience. The first token arrives in under a second. The user's cognitive state shifts from passive waiting to active reading. Even if the total generation time is identical, the perceived speed is dramatically different.

There's also a practical cost angle. When you implement streaming with proper cancellation, users who get enough of an answer midway can stop generation. That cuts API costs on incomplete requests. Depending on your use case, this can reduce monthly LLM spend by 30–50%.

The Core Challenge: React Native Has No EventSource

If you've implemented SSE in a web app, your first instinct might be to reach for EventSource:

// Works in browsers — fails silently in React Native
const es = new EventSource('https://api.anthropic.com/v1/messages');
es.onmessage = (e) => console.log(e.data);

In React Native, this throws or does nothing depending on your setup. The EventSource Web API simply isn't part of the RN runtime.

Streaming means reading a ReadableStream off a fetch() response. And this is where the second trap sits — the one that cost me half a day.

React Native's global fetch does not give you response.body. RN's fetch is a polyfill layered on XMLHttpRequest, and stream support was never implemented (facebook/react-native#27741 has been open for years). Upgrading your Expo SDK does not change this.

What you want is expo/fetch, added in Expo SDK 52. It is a separate WinterCG-compliant implementation that you pull in as a named import. It does not replace the global, so if you forget the import line your code quietly falls back to the old implementation — and that is genuinely hard to spot.

// ❌ The global fetch — the request succeeds, but body is undefined
const bad = await fetch(url, { method: 'POST', body });
bad.body; // → undefined (RN polyfill)
 
// ✅ Expo SDK 52+. The named import is not optional
import { fetch } from 'expo/fetch';
 
const response = await fetch('https://api.anthropic.com/v1/messages', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'anthropic-version': '2023-06-01',
    'x-api-key': 'YOUR_API_KEY', // Use a proxy in production — more on this below
  },
  body: JSON.stringify({
    model: 'claude-haiku-4-5-20251001',
    max_tokens: 1024,
    messages: [{ role: 'user', content: 'Hello' }],
    stream: true,
  }),
});
 
const reader = response.body?.getReader();
const decoder = new TextDecoder();
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value, { stream: true });
  // Parse SSE events from chunk
}

When response.body comes back as undefined, it is almost always one of three causes.

SymptomCauseFix
body is undefined, no error thrownYou are on the global fetchAdd import { fetch } from 'expo/fetch';
Still undefined after importingExpo SDK 51 or earlierUpgrade to SDK 52+, or fall back to a library such as react-native-sse
Works on web, fails on device onlyEXPO_PUBLIC_USE_RN_FETCH=1 is setThat flag restores the RN implementation as the global. Named imports are unaffected, so check your import path first

Rork scaffolds projects on a recent SDK, but whether the generated code actually calls expo/fetch depends on what it wrote for you. When adding streaming, read the import line with your own eyes before anything else.

Parsing SSE Format Correctly

The SSE format that Anthropic sends looks like this:

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", world"}}

event: message_stop
data: {"type":"message_stop"}

OpenAI uses a slightly different format:

data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"Hello"},"index":0}]}

data: [DONE]

A critical detail: one reader.read() call can return multiple SSE events, and a single event can span across multiple reads. If you parse each chunk directly without buffering, you'll see intermittent JSON parse errors in production that are maddeningly hard to reproduce.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Developers stuck on SSE fetch implementation in React Native can now run LLM streaming in production starting today
✦You'll get copy-paste-ready code that covers AbortController cancellation, error retries, and API cost optimization as a complete set
✦You'll learn a multi-provider abstraction pattern covering Anthropic, OpenAI, and Gemini — enabling provider switching at runtime without a single line of app code change
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.
⬡ Dev Tools2026-08-22
Every bulk replace exited zero. The damage was in the lines I did not delete
Run a bulk replace over generated code and the breakage lands on the neighbouring lines, not the matched ones. Here is what broke in a live project, and a dependency-free guard that checks the invariants a replace must preserve.
⬡ Dev Tools2026-07-30
What Renovate may bump in an Expo app, and what it must never touch
Turning on automated dependency updates in a Rork-generated app also hands Renovate the 123 packages Expo SDK 57 pins. Measured on 2026-07-30, six of them sit a full major version behind npm latest. Here is how to generate the ignore list from the SDK instead of maintaining it by hand.