← Blog

Claude API Streaming Response in a Flutter App

2026-10-15 · 4 min read · SubToAPI Team

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

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.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →