Claude API Streaming Response in a Flutter App
Streaming a Claude API response into a Flutter app means consuming a server-sent events (SSE) stream over HTTP and rendering each text chunk as it arrives, instead of waiting for the full response. Dart's http package supports streamed responses natively, so you don't need a special SSE library — you parse the data: lines yourself and update your widget state on each chunk.
The two things that actually make this hard in Flutter are (1) correctly reading a chunked HTTP body as a stream rather than buffering it, and (2) decoding Claude's SSE event format, which sends multiple event types (message_start, content_block_delta, message_stop, etc.) rather than plain text chunks like some other APIs. Below is a complete, working approach.
Why streaming matters for mobile UX
Without streaming, a user taps "send" and stares at a loading spinner until the entire response is generated — which can be several seconds for longer answers. Streaming lets you show text appearing word-by-word, which feels instant and matches the UX users expect from ChatGPT-style apps. It also lets you cancel generation early if the user navigates away, saving tokens and cost.
Setting up the HTTP client for streaming in Dart
Flutter's http package exposes Client.send() which returns a StreamedResponse — this is what you want instead of http.post(), which waits for the full body.
import 'package:http/http.dart' as http;
import 'dart:convert';
Stream<String> streamClaudeResponse({
required String apiKey,
required String prompt,
}) async* {
final client = http.Client();
final request = http.Request(
'POST',
Uri.parse('https://api.anthropic.com/v1/messages'),
);
request.headers.addAll({
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
});
request.body = jsonEncode({
'model': 'claude-sonnet-4-5-20250929',
'max_tokens': 1024,
'stream': true,
'messages': [
{'role': 'user', 'content': prompt}
],
});
final streamedResponse = await client.send(request);
await for (final line in streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (line.startsWith('data: ')) {
final payload = line.substring(6);
if (payload == '[DONE]') break;
final event = jsonDecode(payload);
if (event['type'] == 'content_block_delta') {
final text = event['delta']?['text'];
if (text != null) yield text as String;
}
}
}
client.close();
}
This is a Dart generator function (async* / yield) that turns the raw SSE lines into a clean stream of text deltas. Your widget just subscribes to it.
Wiring the stream into a widget
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
String _response = '';
StreamSubscription<String>? _subscription;
void _ask(String prompt) {
setState(() => _response = '');
_subscription = streamClaudeResponse(
apiKey: const String.fromEnvironment('CLAUDE_API_KEY'),
prompt: prompt,
).listen(
(chunk) => setState(() => _response += chunk),
onError: (e) => setState(() => _response += '\n[error: $e]'),
onDone: () => debugPrint('stream complete'),
);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(_response),
),
);
}
}
Each incoming chunk triggers setState, which is fine for typical response lengths, but for very long streams you may want to throttle updates (batch every 2-3 chunks) to avoid excessive rebuilds on lower-end devices.
Handling cancellation and cleanup
Always cancel the StreamSubscription in dispose() — otherwise a user who navigates away mid-stream leaves a dangling HTTP connection and your widget tries to call setState on an unmounted tree, which throws. Wrapping the setState call in a mounted check is cheap insurance:
onData: (chunk) {
if (mounted) setState(() => _response += chunk);
}
Why call the API directly from a mobile app is risky
Embedding your Anthropic API key in a Flutter app — even obfuscated — means it ships inside the compiled binary, and anyone with basic reverse-engineering tools can extract it. This is the same problem every mobile app calling an LLM API directly runs into: client-side secrets aren't secrets.
The common fix is routing requests through your own backend, but that's extra infrastructure to build and maintain just to proxy a request. SubToAPI solves this more directly: it converts your Claude subscription into scoped sub_live_... application keys that you can safely embed per-app or per-environment, with streaming support identical in shape to Anthropic's own SSE format — so the Dart code above works unchanged, just pointed at https://api.subtoapi.app/v1/messages with your sub_live_ key in the Authorization: Bearer header. You get usage metadata per key, which matters when you're trying to figure out which app build is burning through tokens.
request.headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer $SUBTOAPI_KEY',
});
Check the quickstart and streaming docs for the exact request/response shape, or sign up to generate a scoped key for your Flutter build in a couple of minutes.
Common pitfalls
- Buffering instead of streaming: using
http.post()ordiowithout explicit streaming config will wait for the full response, defeating the purpose. - Partial JSON lines: SSE can split a
data:line across TCP packets in theory, though in practiceLineSplitterhandles this correctly as long as you're transforming the raw byte stream, not pre-decoded strings. - Rebuilding too often: calling
setStateon every single token can cause jank on large screens with complex layouts — batch chunks if you see frame drops. - Forgetting
message_stop: some integrations only check for[DONE], but Claude's stream also emits amessage_stopevent type you can use as a secondary completion signal.
Questions
Does Flutter need a special package for SSE streaming? No. The built-in http package's Client.send() returns a StreamedResponse whose byte stream you decode and line-split manually — no third-party SSE library required.
Can I call the Claude API directly from a Flutter app in production? You can, but your API key ships inside the compiled app binary and can be extracted. For production apps, proxy through a backend or use scoped keys from a service like SubToAPI instead of embedding raw Anthropic credentials.
Why does my Flutter UI freeze during streaming? Usually from calling setState on every token on a widget tree with expensive rebuilds. Batch chunks every few updates, or isolate the streaming text in its own small widget using ValueNotifier to limit rebuild scope.