How to Stream AI Responses in Flutter Without UI Lag or Broken Scrolling
AI chat applications feel dramatically better when users can see the answer appearing progressively instead of waiting several seconds for the entire response.
This is why modern AI applications commonly use streaming responses.
Instead of:
User sends prompt
↓
Wait 8 seconds
↓
Show complete response
the experience becomes:
User sends prompt
↓
First text arrives
↓
More text arrives
↓
UI updates continuously
↓
Final response completes
OpenAI, Gemini and Claude all support streaming model output. OpenAI’s Responses API can return streaming events when streaming is enabled, Gemini provides streaming interaction APIs, and Claude’s Messages API can incrementally return content through Server-Sent Events.
However, simply receiving a stream is not enough.
A poorly implemented Flutter AI chat can quickly develop problems such as:
- UI jank while tokens arrive
- excessive widget rebuilds
- Markdown rendering lag
ListViewjumping during generation- forced auto-scrolling while the user is reading older messages
- broken scrolling after long responses
- duplicate streamed text
- memory leaks from forgotten subscriptions
- streams continuing after leaving the screen
- poor Stop Generation behavior
- long chats becoming progressively slower
The challenge is therefore not:
How do I receive streamed AI text?
The real production question is:
How do I stream AI responses in Flutter while keeping rendering, scrolling and state management smooth?
This guide explains that architecture in detail.
Read : GenUI + Firebase AI in Flutter (2026): Building Dynamic, AI-Driven User Interfaces
What Is AI Response Streaming?
Without streaming, your application waits for the model to finish generating the entire response before displaying it.
For example:
Prompt
↓
API processing
↓
Full 1,500-word response generated
↓
HTTP response returned
↓
Flutter displays everything
If generation takes eight seconds, the user may stare at a loading indicator for eight seconds.
With streaming:
Prompt
↓
The
↓
The best
↓
The best way
↓
The best way to
↓
The best way to fix...
small pieces of output become available while generation is still continuing.
These pieces are often called:
- chunks,
- deltas,
- events,
- fragments,
- or tokens,
depending on the API and SDK.
Streaming improves perceived latency because the user can begin reading before generation is complete.
Read : How to Build AI in Dart & Flutter (Beginner to Advanced Guide with DartPad Examples) – 2026
OpenAI, Gemini and Claude All Support Streaming
The implementation details differ between providers, but the concept is similar.
OpenAI
OpenAI’s Responses API supports streaming responses and exposes incremental events such as text deltas while a response is being generated.
Gemini
Google’s Gemini documentation states that normal generation returns after completion, while streaming allows response chunks to be handled as they are generated. Current Gemini interaction examples use streaming events and text deltas.
Claude
Anthropic’s Messages API supports streaming through Server-Sent Events. A streamed message can contain events including message start, content-block deltas and message completion events.
For Flutter architecture, this means all three providers can eventually be transformed into a common abstraction:
Stream<String> generateStream(String prompt);
That abstraction is extremely useful.
The Most Common Flutter Streaming Mistake
Developers often begin with something like:
stream.listen((chunk) {
setState(() {
response += chunk;
});
});
Technically, this works.
But imagine the provider emits:
100
or:
300
small chunks during one response.
You may now trigger hundreds of widget rebuilds.
If your entire chat page rebuilds every time, Flutter may repeatedly rebuild:
- app bar,
- message list,
- every existing chat bubble,
- Markdown widgets,
- avatars,
- buttons,
- timestamps,
- input field,
- typing indicators,
- animations.
For a short answer, this may still feel fine.
For a long response on a mid-range Android device, it can become visibly inefficient.
Why AI Streaming Can Cause UI Lag
The network stream itself is usually not the expensive part.
The expensive part is often what your application does after every chunk arrives.
Consider:
AI chunk arrives
↓
Append string
↓
setState()
↓
Rebuild chat screen
↓
Reparse Markdown
↓
Relayout growing message
↓
Update ListView height
↓
Auto-scroll
Now repeat this 200 times.
That is where jank begins.
Read : Gemini API in Flutter Using Firebase AI Logic — Complete Production Guide
Production Rule #1: Do Not Rebuild the Entire Chat for Every Token
Your stable message history and your currently streaming message should not necessarily use the same update mechanism.
A better model is:
Completed Messages
↓
Stable / rarely rebuilt
Current Streaming Message
↓
Frequently updated
That separation dramatically reduces unnecessary work.
A Better Message Model
Start with a simple immutable message model.
enum ChatRole {
user,
assistant,
}
class ChatMessage {
final String id;
final ChatRole role;
final String text;
const ChatMessage({
required this.id,
required this.role,
required this.text,
});
ChatMessage copyWith({
String? text,
}) {
return ChatMessage(
id: id,
role: role,
text: text ?? this.text,
);
}
}
Your state might then contain:
final List<ChatMessage> messages = [];
final ValueNotifier<String> streamingText =
ValueNotifier<String>('');
Completed history lives in:
messages
while the currently growing assistant response lives in:
streamingText
This means you can update the streaming bubble without rebuilding the complete page.
Use ValueListenableBuilder for the Active Streaming Bubble
For example:
ValueListenableBuilder<String>(
valueListenable: streamingText,
builder: (context, value, child) {
return AssistantBubble(
text: value,
isStreaming: true,
);
},
)
Now only the widget below that builder needs to react to the changing text.
This is often much more efficient than:
setState(() {
messages[lastIndex] = updatedMessage;
});
for every small fragment.
Read : How to Fix AI API 429 Rate Limit Errors in Flutter — OpenAI, Gemini and Claude
StreamBuilder Is Useful, But It Does Not Automatically Solve Performance
Flutter’s StreamBuilder rebuilds itself based on snapshots received from a stream. Flutter’s documentation describes it as a widget that builds itself based on interaction with a specified Stream.
A basic example:
StreamBuilder<String>(
stream: aiStream,
builder: (context, snapshot) {
return Text(
snapshot.data ?? '',
);
},
)
This is perfectly valid for many cases.
But StreamBuilder is not automatically an AI chat performance solution.
If your stream emits individual tiny fragments rapidly, the builder can still be invoked frequently.
The real optimization is controlling:
- stream granularity,
- state ownership,
- rendering scope,
- Markdown parsing,
- scrolling,
- cancellation.
Do Not Assume Every Chunk Is a Complete Word
A streamed provider may return:
"Fl"
"utt"
"er"
" is"
" a"
rather than:
"Flutter"
"is"
"a"
Never design your parsing logic around one chunk equalling:
- one token,
- one word,
- one sentence.
Treat each chunk simply as:
String delta
and append it in order.
Use StringBuffer for Accumulating Long Responses
Instead of repeatedly doing:
response = response + chunk;
use:
final buffer = StringBuffer();
buffer.write(chunk);
Then retrieve:
buffer.toString();
For long responses, a buffer is a cleaner way to accumulate incremental text.
A Basic AI Streaming Service Interface
Create a provider-independent contract.
abstract class AiStreamService {
Stream<String> generate({
required String prompt,
});
}
Then implementations can include:
OpenAiStreamService
GeminiStreamService
ClaudeStreamService
Your UI does not need to know which provider is being used.
It simply receives:
Stream<String>
This architecture also makes future provider switching easier.
Read : How to Run Gemma 3 1B On-Device AI in Flutter – Complete Offline AI Guide
Recommended Architecture
A production Flutter AI chat can look like:
Chat Screen
↓
Chat Controller / ViewModel
↓
AI Repository
↓
Backend API
↓
OpenAI / Gemini / Claude
The stream flows back:
Provider
↓
Backend
↓
AI Repository
↓
Chat Controller
↓
Streaming Bubble
Your widgets should not contain all provider-specific networking logic.
Important Security Note
For production applications, avoid embedding permanent OpenAI, Gemini or Claude secrets directly inside the Flutter application.
A mobile application can be inspected and reverse engineered.
A stronger architecture is:
Flutter
↓
Your Backend
↓
AI Provider
The Flutter application can receive a safe streamed response from your own backend.
This also allows you to centralize:
- authentication,
- API keys,
- quotas,
- rate limits,
- abuse prevention,
- logging,
- provider fallback.
Server-Sent Events and Flutter
Many AI APIs use Server-Sent Events, commonly abbreviated as:
SSE
Conceptually an SSE response may look like:
event: response.output_text.delta
data: {...}
event: response.output_text.delta
data: {...}
event: response.completed
data: {...}
The application parses each event and extracts relevant text.
Claude explicitly documents its Messages stream as an SSE stream, while OpenAI also supports event-based streaming responses.
Generic Flutter Streaming Example
Suppose your repository already exposes:
Stream<String> streamAnswer(String prompt)
Your controller could look like:
class ChatController {
final AiStreamService aiService;
ChatController(this.aiService);
final ValueNotifier<String> streamingText =
ValueNotifier<String>('');
StreamSubscription<String>? _subscription;
bool get isStreaming => _subscription != null;
Future<void> generate(String prompt) async {
await stop();
streamingText.value = '';
final buffer = StringBuffer();
_subscription = aiService
.generate(prompt: prompt)
.listen(
(chunk) {
buffer.write(chunk);
streamingText.value = buffer.toString();
},
onError: (error, stackTrace) {
_subscription = null;
},
onDone: () {
_subscription = null;
},
cancelOnError: true,
);
}
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
}
void dispose() {
_subscription?.cancel();
streamingText.dispose();
}
}
This works, but we can improve it further.
The Hidden Problem: Providers Can Emit Chunks Very Quickly
Imagine the stream emits:
Chunk 1 → 0 ms
Chunk 2 → 8 ms
Chunk 3 → 14 ms
Chunk 4 → 23 ms
Chunk 5 → 30 ms
There is little reason to repaint the UI independently for every chunk.
A human cannot perceive every 5 ms update.
Instead, accumulate incoming text and publish it to the UI at a controlled cadence.
Read : How to Secure AI API Keys in Flutter Apps — Why .env Is Not Enough
Batch Streaming Updates
One useful strategy is:
Network chunks
↓
StringBuffer
↓
Update UI every ~30–80 ms
This can significantly reduce rebuild pressure while still looking continuous.
Example: Throttled Streaming Controller
import 'dart:async';
class ChatController {
final AiStreamService aiService;
ChatController(this.aiService);
final ValueNotifier<String> streamingText =
ValueNotifier<String>('');
StreamSubscription<String>? _subscription;
Timer? _flushTimer;
final StringBuffer _buffer = StringBuffer();
bool _dirty = false;
bool get isStreaming => _subscription != null;
Future<void> generate(String prompt) async {
await stop();
_buffer.clear();
streamingText.value = '';
_dirty = false;
_flushTimer = Timer.periodic(
const Duration(milliseconds: 50),
(_) => _flush(),
);
_subscription = aiService
.generate(prompt: prompt)
.listen(
(chunk) {
_buffer.write(chunk);
_dirty = true;
},
onError: (error, stackTrace) {
_finish();
},
onDone: _finish,
cancelOnError: true,
);
}
void _flush() {
if (!_dirty) return;
streamingText.value = _buffer.toString();
_dirty = false;
}
void _finish() {
_flush();
_flushTimer?.cancel();
_flushTimer = null;
_subscription = null;
}
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
_flush();
_flushTimer?.cancel();
_flushTimer = null;
}
void dispose() {
_subscription?.cancel();
_flushTimer?.cancel();
streamingText.dispose();
}
}
Now your network can deliver 200 chunks while the visible UI may update far fewer times.
The response still feels live.
Why 50 ms Is a Reasonable Starting Point
There is no universal perfect interval.
Common practical ranges can be around:
30–100 milliseconds
depending on:
- device performance,
- Markdown complexity,
- response size,
- animation requirements,
- provider chunk frequency.
Do not blindly copy one number.
Profile your application.
The goal is:
Smooth enough visually
+
Not unnecessarily rebuilding
Do Not Add Artificial Typewriter Delay Unless You Need It
Streaming and typewriter animations are different things.
Real streaming:
Text appears when server sends it
Artificial typewriter effect:
Full text may already exist
but app reveals characters slowly
Adding an unnecessary delay can actually make a fast AI model feel slower.
Usually, publish actual buffered stream data at a reasonable UI cadence instead.
The Second Major Problem: Broken Auto-Scrolling
Streaming chat introduces a unique scrolling problem.
As the assistant response grows:
Bubble height
↓
increases
↓
ListView content height changes
If your application automatically scrolls after every update, the user may lose control of the conversation.
The Wrong Auto-Scroll Implementation
A common implementation is:
stream.listen((chunk) {
setState(() {
response += chunk;
});
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
This creates multiple problems.
Every new chunk:
- rebuilds UI,
- starts a new animation,
- changes the content height,
- interrupts the previous animation,
- starts another animation.
During a long response this can produce:
- shaking,
- jumping,
- inconsistent scroll position,
- animation backlog,
- user scroll being overridden.
Production Rule #2: Auto-Scroll Only When the User Is Already Near the Bottom
If the user is reading the latest answer, automatically following the stream makes sense.
If the user scrolls upward to read an older message, the application should stop forcing them down.
This is the same behavior users expect from modern messaging applications.
Detect Whether the User Is Near the Bottom
Create a helper:
bool isNearBottom(
ScrollController controller, {
double threshold = 120,
}) {
if (!controller.hasClients) {
return false;
}
final position = controller.position;
final distanceFromBottom =
position.maxScrollExtent - position.pixels;
return distanceFromBottom <= threshold;
}
Flutter’s ScrollController exposes the attached scroll position and can be used to read or modify the scroll offset. Flutter recommends storing controllers as state members and reusing them rather than recreating them in each build.
Track Auto-Follow State
For example:
bool shouldAutoFollow = true;
Listen to scrolling:
void onScroll() {
shouldAutoFollow = isNearBottom(
scrollController,
);
}
Register it:
@override
void initState() {
super.initState();
scrollController.addListener(onScroll);
}
Now when the user scrolls upward:
shouldAutoFollow = false
When they return close to the bottom:
shouldAutoFollow = true
Scroll After Layout, Not Before It
Another common bug occurs because developers call:
scrollController.position.maxScrollExtent
immediately after changing the message text.
At that moment Flutter may not yet have completed layout for the larger chat bubble.
So maxScrollExtent can still represent the old content height.
Use:
WidgetsBinding.instance.addPostFrameCallback(
(_) {
// scroll here
},
);
This allows the new layout to finish first.
Smart Auto-Scroll Function
void scrollToBottomIfNeeded() {
if (!shouldAutoFollow) return;
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!scrollController.hasClients) return;
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
);
},
);
}
But there is still another issue.
Do Not Animate on Every Stream Update
Even if you update the visible text every 50 ms, creating an animation every 50 ms is unnecessary.
A better strategy is to throttle scrolling separately.
For example:
Text refresh → every 50 ms
Scroll refresh → every 100–200 ms
Or use:
jumpTo()
for very frequent tiny adjustments while the user remains pinned to the latest response.
animateTo vs jumpTo During Streaming
There is no universal rule.
animateTo()
Advantages:
- visually smooth,
- good when adding a complete message,
- good for explicit “jump to latest” interactions.
Disadvantages:
- repeated animations can conflict during rapid streaming.
jumpTo()
Advantages:
- immediate,
- cheap,
- useful when maintaining a continuously pinned bottom position.
Disadvantages:
- can look abrupt for large changes.
A practical design is:
New complete message
→ animateTo()
Tiny streaming growth while pinned
→ jumpTo() or throttled animateTo()
Example Pinned-Bottom Scroll
void followStreamToBottom() {
if (!shouldAutoFollow) return;
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!scrollController.hasClients) return;
final max =
scrollController.position.maxScrollExtent;
scrollController.jumpTo(max);
},
);
}
Call this after your throttled visible text update rather than every network packet.
Show a “Jump to Latest” Button
When the user scrolls upward:
shouldAutoFollow = false
show a floating button:
↓ Latest
Example:
if (!shouldAutoFollow)
Positioned(
right: 16,
bottom: 90,
child: FloatingActionButton.small(
onPressed: () {
setState(() {
shouldAutoFollow = true;
});
scrollToBottom();
},
child: const Icon(
Icons.keyboard_arrow_down,
),
),
)
This creates a much better user experience than forcing scrolling.
ListView.builder Is Preferable for Long Chat Histories
Instead of:
ListView(
children: messages.map(buildMessage).toList(),
)
prefer:
ListView.builder(
controller: scrollController,
itemCount: messages.length,
itemBuilder: (context, index) {
return MessageBubble(
message: messages[index],
);
},
)
ListView.builder creates children on demand and is designed for lists where items should be built lazily rather than creating every widget upfront.
For long AI conversations, this becomes increasingly important.
Avoid shrinkWrap: true for the Main Chat List
A pattern like:
SingleChildScrollView(
child: Column(
children: [
ListView.builder(
shrinkWrap: true,
...
),
],
),
)
is usually a poor structure for a large chat screen.
The primary chat list should normally be the actual scrollable:
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: messages.length,
itemBuilder: ...,
),
)
This allows Flutter’s viewport and lazy list behavior to work properly.
Recommended Chat Layout
Column(
children: [
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: messages.length,
itemBuilder: buildMessage,
),
),
ChatInput(),
],
)
Simple architecture is usually the best architecture.
Markdown Is Often the Biggest Performance Cost
AI output frequently includes:
# Headings
**Bold text**
- Lists
```dart
code();
Quotes
Developers therefore commonly render streamed responses using Markdown.
The problem is that every streamed update can cause the entire growing Markdown document to be reparsed and rendered.
Imagine:
```text
20 characters
↓
parse markdown
40 characters
↓
parse markdown again
60 characters
↓
parse markdown again
...
10,000 characters
↓
parse entire markdown again
This can become expensive.
Read : OpenAI vs Gemini vs Claude for Flutter Apps — Cost, Speed, Features and Best Use Cases
Solution: Reduce Markdown Rebuild Frequency
This is another reason batching stream updates is useful.
Instead of Markdown parsing for every tiny delta:
Delta
Delta
Delta
Delta
Delta
collect them briefly:
Buffer
↓
UI update
↓
Markdown parse
A 40–80 ms batching window can dramatically reduce the number of Markdown parses.
Another Optimization: Plain Text During Streaming
For extremely long or complex responses, one strategy is:
While streaming
→ render optimized plain/selectable text
After completion
→ render full Markdown
Example:
if (isStreaming) {
return SelectableText(streamingText);
}
return MarkdownBody(
data: completedText,
);
This is not always necessary.
But for apps experiencing Markdown jank, it can be very effective.
Hybrid Markdown Rendering
Another approach is:
Completed paragraphs
→ Markdown
Current incomplete paragraph
→ Plain text
This requires more implementation effort but can reduce reparsing of large completed sections.
For most Flutter apps, start with batched updates before building more complicated rendering logic.
Keep Completed Messages Immutable
Do not continuously mutate every message object.
Once an assistant response has completed:
Streaming buffer
↓
Create final ChatMessage
↓
Append to history
↓
Clear streaming state
Example:
void finalizeAssistantMessage() {
final text = streamingText.value;
messages.add(
ChatMessage(
id: DateTime.now()
.microsecondsSinceEpoch
.toString(),
role: ChatRole.assistant,
text: text,
),
);
streamingText.value = '';
}
This keeps your state model predictable.
Complete Chat State Model
A useful state can include:
class ChatState {
final List<ChatMessage> messages;
final bool isGenerating;
final String? error;
const ChatState({
required this.messages,
required this.isGenerating,
this.error,
});
}
And keep rapidly changing streaming content separately:
ValueNotifier<String> streamingText;
This means your global state does not need to emit hundreds of objects for one answer.
Riverpod Strategy
If using Riverpod, avoid unnecessarily replacing the entire conversation state for every token.
Instead, you can separate providers:
chatHistoryProvider
for completed messages and:
streamingTextProvider
for the active response.
Conceptually:
final chatMessagesProvider =
NotifierProvider<ChatNotifier, List<ChatMessage>>(
ChatNotifier.new,
);
final streamingTextProvider =
StateProvider<String>((ref) => '');
The streaming bubble watches:
ref.watch(streamingTextProvider)
while completed bubbles do not need to rebuild.
GetX Strategy
The same concept applies with GetX.
Instead of:
RxList<ChatMessage> messages
being modified every tiny chunk, use:
final streamingText = ''.obs;
for the active response.
Then only the active bubble reacts.
Provider / ChangeNotifier Strategy
With ChangeNotifier, consider:
ValueNotifier<String>
for the high-frequency stream rather than calling:
notifyListeners();
on a large screen-level model every few milliseconds.
Bloc Strategy
With Bloc, emitting an entirely new large chat state for every tiny network chunk can create unnecessary state traffic.
Consider:
- batching deltas,
- emitting at controlled intervals,
- using a dedicated streaming state,
- using
BlocSelector, - isolating the streaming bubble.
Again, the principle matters more than the state-management library.
Production Rule #3: Separate Network Frequency From UI Frequency
This is one of the most important concepts in the entire article.
The server might send data:
200 times
That does not mean your UI needs to repaint:
200 times
Treat them as separate layers.
Network frequency
↓
Buffer
↓
UI frequency
This is effectively backpressure at the presentation layer.
Stop Generation Must Actually Cancel the Stream
A Stop button that only hides the loading indicator is not enough.
Bad:
void stopGenerating() {
setState(() {
isGenerating = false;
});
}
The HTTP connection may still be active.
Tokens may continue arriving.
You may continue paying for output.
Instead, cancel the underlying stream subscription or request where supported.
Flutter StreamSubscription Cancellation
Store the subscription:
StreamSubscription<String>? subscription;
Start:
subscription = aiStream.listen(
onChunk,
);
Stop:
await subscription?.cancel();
subscription = null;
If your networking layer supports request cancellation, propagate cancellation all the way down.
Stop Button Example
IconButton(
onPressed: controller.isStreaming
? controller.stop
: null,
icon: const Icon(
Icons.stop_circle_outlined,
),
)
When stopped:
- cancel provider/backend stream,
- flush buffered text,
- preserve partial response if desired,
- mark generation complete,
- stop progress indicators.
Should Partial Text Be Preserved After Stop?
Usually yes.
If the user manually stops after receiving:
The three main reasons for this Flutter error are...
deleting everything is frustrating.
You can preserve the partial answer and optionally mark it:
Stopped
or:
Generation stopped by user
Dispose Stream Resources
Always clean up:
@override
void dispose() {
subscription?.cancel();
scrollController.dispose();
streamingText.dispose();
super.dispose();
}
Otherwise you may encounter:
- updates after widget disposal,
- memory leaks,
- background network work,
- exceptions caused by invalid UI state.
Avoid “setState() Called After dispose()”
This common streaming bug occurs when:
User starts AI request
↓
User navigates away
↓
Stream receives new chunk
↓
setState()
↓
Widget already disposed
Proper subscription cancellation prevents much of this.
If using asynchronous callbacks directly, also check:
if (!mounted) return;
before updating widget state.
Read : How to use statefulBuilder for refreshing specific widget with setState() ?
Handle Stream Errors Separately From Successful Completion
Your stream should distinguish:
onData
onError
onDone
Example:
subscription = stream.listen(
(chunk) {
// append chunk
},
onError: (error, stackTrace) {
handleStreamError(error);
},
onDone: () {
finalizeMessage();
},
);
Do not treat network interruption as a valid completed response.
Streaming Errors Are Different From Normal HTTP Errors
A normal request can fail before receiving any response.
Streaming introduces another scenario:
Request starts
↓
400 words received
↓
Connection drops
Now you have a partial response.
Your UI needs to decide whether to:
- preserve it,
- retry,
- offer Continue,
- regenerate,
- show an interruption warning.
Read : Codex CLI, OpenAI Codex, ChatGPT Codex — How to Build Flutter Apps Smartly in 2026
Do Not Automatically Duplicate Partial Responses
Imagine the user already received:
Flutter's rendering pipeline consists of...
Then the stream fails.
If you automatically restart the entire request and append the new response:
Flutter's rendering pipeline consists of...
Flutter's rendering pipeline consists of...
you create duplicated output.
Streaming retries need more care than normal request retries.
A Better Interrupted-Stream UI
Show:
Response interrupted.
[Continue] [Regenerate]
This gives the user control.
For provider-specific continuation behavior, your backend can decide whether to:
- continue from partial context,
- issue a new generation,
- or restore an existing stream where supported.
Smart Scrolling Architecture
A robust chat scrolling flow can be represented as:
Stream chunk arrives
↓
Buffer text
↓
UI flush interval reached
↓
Update streaming bubble
↓
Was user near bottom?
↙ ↘
Yes No
↓ ↓
Follow new text Do nothing
↓
Show “Latest” button
That single rule eliminates many frustrating scrolling bugs.
What About ListView(reverse: true)?
Many chat applications use:
ListView.builder(
reverse: true,
)
With reverse: true, offset calculations and logical message order behave differently.
This can make inserting older messages convenient, but it can also make streaming scroll logic more confusing.
Neither approach is universally correct.
Normal ListView
reverse: false
Advantages:
- intuitive ordering,
- bottom is
maxScrollExtent, - easier to reason about for beginners.
Reverse ListView
reverse: true
Advantages:
- common in messaging UIs,
- latest content can align with offset zero,
- useful for paginating older messages.
Choose one architecture and write your scrolling logic specifically for it.
Do not copy maxScrollExtent code designed for a normal list into a reversed list without understanding the difference.
Read : How to add a ListView to a Column in Flutter?
Paginate Old Chat Messages
Even ListView.builder does not mean you should keep unlimited AI history in memory forever.
For long-lived conversations:
Newest 50 messages
↓
Displayed
Older messages
↓
Load when user scrolls upward
This reduces:
- memory usage,
- state size,
- build complexity,
- startup time.
Give Message Widgets Stable Keys
For example:
MessageBubble(
key: ValueKey(message.id),
message: message,
)
Stable identity helps Flutter preserve widget state correctly when lists change.
Avoid Rebuilding Avatars and Static UI
Extract stable widgets.
Instead of rebuilding:
Avatar
Name
Timestamp
Copy button
Reaction buttons
Markdown body
every stream update, isolate the changing text from unchanged elements.
For example:
AssistantMessageShell(
avatar: const AssistantAvatar(),
child: ValueListenableBuilder<String>(
valueListenable: streamingText,
builder: ...,
),
)
Use RepaintBoundary Selectively
Flutter list delegates commonly introduce repaint boundaries for children, and ListView.builder exposes addRepaintBoundaries behavior through its child delegate.
For custom complex chat components, isolating expensive visual regions may help.
However, do not randomly wrap everything in RepaintBoundary.
Profile first.
Avoid Heavy Work Inside build()
Do not perform operations such as:
jsonDecode(...)
complex transformations, token counting, or expensive text preprocessing directly inside:
build()
especially when the streaming bubble rebuilds frequently.
Precompute what you can.
Code Highlighting Can Become Expensive
AI responses often generate long code blocks.
Syntax highlighting may involve additional parsing.
A streamed response containing:
class VeryLargeExample {
...
}
can force repeated parsing as each fragment arrives.
For code-heavy AI apps consider:
During streaming
→ simpler code rendering
After completion
→ full syntax highlighting
or apply a lower refresh frequency.
ScrollController Lifecycle
Do not create:
ScrollController()
inside build().
Wrong:
Widget build(BuildContext context) {
final controller = ScrollController();
return ListView(
controller: controller,
);
}
Flutter’s ScrollController documentation recommends storing controllers as state members so they can be reused across builds.
Correct:
late final ScrollController scrollController;
@override
void initState() {
super.initState();
scrollController = ScrollController();
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
Complete Simplified Streaming Chat Example
Here is a compact architecture combining several techniques.
class StreamingChatPage extends StatefulWidget {
const StreamingChatPage({
super.key,
required this.aiService,
});
final AiStreamService aiService;
@override
State<StreamingChatPage> createState() =>
_StreamingChatPageState();
}
class _StreamingChatPageState
extends State<StreamingChatPage> {
late final ScrollController _scrollController;
final ValueNotifier<String> _streamingText =
ValueNotifier<String>('');
final List<ChatMessage> _messages = [];
StreamSubscription<String>? _subscription;
Timer? _flushTimer;
final StringBuffer _buffer = StringBuffer();
bool _dirty = false;
bool _autoFollow = true;
bool _isStreaming = false;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(
_handleScroll,
);
}
void _handleScroll() {
if (!_scrollController.hasClients) {
return;
}
final position =
_scrollController.position;
final distanceFromBottom =
position.maxScrollExtent -
position.pixels;
final next =
distanceFromBottom < 120;
if (_autoFollow != next) {
setState(() {
_autoFollow = next;
});
}
}
Future<void> _send(
String prompt,
) async {
if (_isStreaming) return;
setState(() {
_messages.add(
ChatMessage(
id: DateTime.now()
.microsecondsSinceEpoch
.toString(),
role: ChatRole.user,
text: prompt,
),
);
_isStreaming = true;
_autoFollow = true;
});
_buffer.clear();
_streamingText.value = '';
_dirty = false;
_flushTimer = Timer.periodic(
const Duration(milliseconds: 50),
(_) => _flush(),
);
_subscription = widget.aiService
.generate(prompt: prompt)
.listen(
(chunk) {
_buffer.write(chunk);
_dirty = true;
},
onError: (error, stackTrace) {
_completeStream(
interrupted: true,
);
},
onDone: () {
_completeStream();
},
cancelOnError: true,
);
_scrollToBottom();
}
void _flush() {
if (!_dirty) return;
_streamingText.value =
_buffer.toString();
_dirty = false;
if (_autoFollow) {
_followStream();
}
}
void _followStream() {
WidgetsBinding.instance
.addPostFrameCallback(
(_) {
if (!_scrollController.hasClients ||
!_autoFollow) {
return;
}
_scrollController.jumpTo(
_scrollController
.position
.maxScrollExtent,
);
},
);
}
void _scrollToBottom() {
WidgetsBinding.instance
.addPostFrameCallback(
(_) {
if (!_scrollController.hasClients) {
return;
}
_scrollController.animateTo(
_scrollController
.position
.maxScrollExtent,
duration:
const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
},
);
}
void _completeStream({
bool interrupted = false,
}) {
_flush();
_flushTimer?.cancel();
_flushTimer = null;
_subscription = null;
final text = _buffer.toString();
if (text.isNotEmpty) {
setState(() {
_messages.add(
ChatMessage(
id: DateTime.now()
.microsecondsSinceEpoch
.toString(),
role: ChatRole.assistant,
text: text,
),
);
_isStreaming = false;
});
} else {
setState(() {
_isStreaming = false;
});
}
_streamingText.value = '';
}
Future<void> _stop() async {
await _subscription?.cancel();
_subscription = null;
_completeStream(
interrupted: true,
);
}
@override
void dispose() {
_subscription?.cancel();
_flushTimer?.cancel();
_streamingText.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final extraStreamingItem =
_isStreaming ? 1 : 0;
return Scaffold(
body: Column(
children: [
Expanded(
child: Stack(
children: [
ListView.builder(
controller:
_scrollController,
padding:
const EdgeInsets.all(16),
itemCount:
_messages.length +
extraStreamingItem,
itemBuilder:
(context, index) {
if (index <
_messages.length) {
return MessageBubble(
key: ValueKey(
_messages[index].id,
),
message:
_messages[index],
);
}
return ValueListenableBuilder<
String>(
valueListenable:
_streamingText,
builder:
(
context,
text,
child,
) {
return AssistantBubble(
text: text,
isStreaming: true,
);
},
);
},
),
if (!_autoFollow)
Positioned(
right: 16,
bottom: 16,
child:
FloatingActionButton.small(
onPressed: () {
setState(() {
_autoFollow = true;
});
_scrollToBottom();
},
child: const Icon(
Icons
.keyboard_arrow_down,
),
),
),
],
),
),
ChatComposer(
isGenerating: _isStreaming,
onSend: _send,
onStop: _stop,
),
],
),
);
}
}
This example demonstrates the important concepts:
Lazy message list
+
isolated streaming state
+
buffered updates
+
smart auto-follow
+
post-layout scrolling
+
stream cancellation
+
resource cleanup
One Important Improvement to the Example
In a real application, you should also protect against race conditions.
For example:
Request A starts
↓
Request A canceled
↓
Request B starts
↓
Late event from A arrives
Use a request ID or generation ID.
int _generationId = 0;
When starting:
final generation =
++_generationId;
Inside callbacks:
if (generation != _generationId) {
return;
}
When cancelling:
_generationId++;
This prevents stale streams from modifying newer conversations.
Handle Fast Consecutive Prompts
Disable Send while generating, or define explicit behavior for concurrent requests.
Simple implementation:
if (isStreaming) {
return;
}
More advanced AI apps may queue prompts, but do not accidentally run multiple streams into the same StringBuffer.
Do Not Share One Streaming Buffer Between Multiple Messages
Bad:
final StringBuffer responseBuffer =
StringBuffer();
used globally for several simultaneous conversations.
Instead each active generation should own:
- its request ID,
- its stream,
- its buffer,
- its cancellation state.
How to Support Multiple Conversations
Architecture:
Conversation A
↓
ChatController A
Conversation B
↓
ChatController B
Do not let one global streaming notifier represent every chat unless your application genuinely permits only one active conversation.
What Happens When the App Goes to Background?
Mobile lifecycle matters.
Possible approaches:
Continue generation
Useful if your backend persists the response independently.
Cancel generation
Simpler and potentially cheaper.
Persist job on backend
Best for long-running tasks.
Your decision depends on the product.
For normal AI chat, preserving completed text and safely reconnecting is usually better than relying on an indefinitely open mobile connection.
Streaming Over a Backend Is Usually Better
A production system may use:
Flutter
↓
HTTPS / SSE / WebSocket
↓
Your backend
↓
AI provider
Your backend can normalize provider events.
For example:
OpenAI:
{
"type": "delta",
"text": "Flutter"
}
Gemini:
{
"type": "delta",
"text": "Flutter"
}
Claude:
{
"type": "delta",
"text": "Flutter"
}
Flutter then deals with one format.
Normalize Provider Events
Create something such as:
sealed class AiStreamEvent {}
class AiTextDelta
extends AiStreamEvent {
final String text;
AiTextDelta(this.text);
}
class AiStreamCompleted
extends AiStreamEvent {}
class AiStreamFailed
extends AiStreamEvent {
final Object error;
AiStreamFailed(this.error);
}
Then your UI does not care whether the original event came from:
- OpenAI Responses API,
- Gemini interaction stream,
- Claude Messages stream.
Why Event Types Matter
Modern AI APIs can stream more than text.
A stream may eventually contain:
Text delta
Tool call
Tool arguments
Citation
Image event
Audio event
Reasoning summary
Completion event
Error
For example, OpenAI’s Responses API exposes multiple streaming event types, Gemini’s interaction stream uses typed events, and Claude’s SSE format includes several message and content-block event types.
Do not assume every incoming event contains displayable text.
Filter Only Displayable Text
Conceptually:
if (event is AiTextDelta) {
buffer.write(event.text);
}
Tool events should be processed separately.
Tool Calling and Streaming
If your AI application supports function calling:
Assistant text
↓
Tool call
↓
Flutter/backend executes tool
↓
Tool result
↓
AI continues
your state model needs more than a single string.
Use message blocks such as:
TextBlock
ToolCallBlock
ToolResultBlock
ErrorBlock
This becomes important as your Flutter AI app evolves beyond basic chat.
Accessibility Considerations
Updating accessibility semantics for every tiny token can overwhelm screen readers.
For accessibility-friendly streaming, consider announcing:
- when generation begins,
- when generation finishes,
- important status changes,
rather than announcing every fragment.
Battery and Performance Matter on Mobile
A desktop browser may tolerate aggressive rendering better than a low-cost Android phone.
Every unnecessary update can involve:
CPU
layout
painting
text measurement
Markdown parsing
scroll calculations
Streaming design should therefore be tested on actual Android hardware, not only on an emulator or flagship device.
Measure Performance Instead of Guessing
Use Flutter DevTools.
Look for:
- long UI frames,
- excessive rebuilds,
- layout spikes,
- raster spikes,
- memory growth.
Test responses containing:
Long Markdown
Large code blocks
Tables
10,000+ characters
100+ chat messages
A chat that feels smooth with “Hello” is not a meaningful performance test.
Recommended Stress Test
Create a test response containing:
3,000–5,000 words
+
multiple headings
+
10 code blocks
+
several lists
+
100 previous messages
Then test:
- streaming,
- scrolling upward while streaming,
- returning to bottom,
- stopping generation,
- navigating away,
- starting another prompt.
If all six remain smooth, your architecture is in much better shape.
Common Flutter AI Streaming Mistakes
Mistake 1: setState() for Every Token
Problem: excessive rebuilds.
Fix: buffer and isolate streaming state.
Mistake 2: Auto-Scroll on Every Chunk
Problem: jumping and competing animations.
Fix: throttle scrolling and only follow when user is near the bottom.
Mistake 3: Forcing User Back to Bottom
Problem: user cannot read previous messages.
Fix: disable auto-follow when user scrolls away.
Mistake 4: Parsing Full Markdown for Every Tiny Delta
Problem: rendering becomes expensive.
Fix: batch UI updates or simplify rendering while streaming.
Mistake 5: Recreating ScrollController
Problem: lost positions and unstable scrolling.
Fix: maintain one controller through widget state.
Mistake 6: Forgetting Stream Cancellation
Problem: requests continue after screen disposal.
Fix: retain StreamSubscription and cancel it.
Mistake 7: Updating After Widget Disposal
Problem: runtime errors.
Fix: cancel streams and guard asynchronous UI updates.
Mistake 8: Treating Every Event as Text
Problem: broken parsing when providers emit tool/status events.
Fix: create typed stream events.
Mistake 9: Keeping Unlimited Chat History
Problem: increasing memory and rendering cost.
Fix: lazy lists, pagination and sensible conversation state.
Mistake 10: Putting AI Provider Logic Inside Widgets
Problem: difficult maintenance and testing.
Fix: use repository/service/controller layers.
Recommended Production Architecture
A mature architecture looks like:
┌─────────────────────────┐
│ Flutter Chat UI │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Chat Controller/ViewModel│
│ │
│ • buffer │
│ • cancel │
│ • throttle │
│ • finalize │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ AI Repository │
│ │
│ Stream<AiStreamEvent> │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Your Backend │
│ │
│ auth / limits / logging │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ OpenAI / Gemini / Claude│
└─────────────────────────┘
And independently:
ScrollController
↓
Near bottom?
↙ ↘
Yes No
↓ ↓
Follow Preserve
stream user position
Recommended Update Strategy
A useful starting configuration is:
| Operation | Strategy |
|---|---|
| Network chunks | Process immediately |
| String accumulation | Immediate |
| Streaming text UI | Batch around 30–80 ms |
| Auto-scroll | Throttled / post-frame |
| Markdown | Re-render at controlled cadence |
| Completed history | Update once per completed message |
| Stop | Cancel actual stream |
| Screen dispose | Cancel all resources |
These are starting principles, not fixed universal timings.
Always profile your own app.
OpenAI Streaming in Flutter
OpenAI’s current Responses API can stream response events when streaming is enabled. The API exposes typed streaming events, including text-delta events.
Your backend can convert relevant text deltas into:
Stream<String>
for Flutter.
Do not couple your UI directly to every OpenAI event schema unless your application specifically needs those event types.
Recommended flow
OpenAI Response Event
↓
Backend parser
↓
Text delta
↓
Flutter repository
↓
Buffer
↓
Streaming bubble
Gemini Streaming in Flutter
Google’s current Gemini documentation states that streaming allows output chunks to be processed as they are generated rather than waiting for the entire result. Current interaction examples use:
stream: true
and consume text delta events.
The same Flutter principles apply:
Gemini event
↓
extract text
↓
buffer
↓
throttled UI update
Do not make your widget responsible for interpreting every Gemini event.
Claude Streaming in Flutter
Claude’s Messages API can stream incremental output using SSE when:
stream = true
Anthropic documents a lifecycle including:
message_start
content_block_start
content_block_delta
content_block_stop
message_delta
message_stop
with possible ping events as well.
For normal visible output, your service can extract text deltas and forward them into the common Flutter stream abstraction.
OpenAI vs Gemini vs Claude Streaming
| Provider | Streaming Model | Flutter Recommendation |
| OpenAI | Typed Responses streaming events | Extract text deltas in repository/backend |
| Gemini | Streaming interaction events | Normalize text delta events |
| Claude | SSE message/content events | Extract text from content deltas |
| Flutter UI | Stream/state updates | Keep provider-independent |
The ideal UI should not care whether the text originated from OpenAI, Gemini or Claude.
Production Checklist
Before shipping a streaming AI chat in Flutter, verify:
Network Layer
- Provider stream is parsed correctly
- Text deltas remain in correct order
- Non-text events are handled
- Cancellation reaches networking layer
- Stream errors are handled
- API secrets remain server-side where appropriate
State Layer
- Completed messages are stable
- Current streaming message has isolated state
- Network updates are buffered
- UI updates are throttled when necessary
- stale requests cannot overwrite new requests
- partial responses are handled
UI Layer
ListView.builderis used for scalable histories- Main list does not rely on unnecessary
shrinkWrap - Markdown does not reparse excessively
- Send is protected against accidental duplicate streams
- Stop Generation works
Scrolling
ScrollControllerpersists across builds- user can scroll upward during generation
- auto-follow stops when user leaves bottom
- “jump to latest” control appears when needed
- scrolling occurs after layout
- repeated animations are avoided
Lifecycle
- stream cancels during disposal
- timers are cancelled
- notifiers/controllers are disposed
- navigation during streaming does not crash
Final Thoughts
Streaming AI output in Flutter is easy to demonstrate but considerably harder to implement well.
The naive implementation:
stream.listen((chunk) {
setState(() {
text += chunk;
});
scrollToBottom();
});
may work during a prototype.
But production AI applications need more control.
A better architecture is:
AI Stream
↓
Parse Events
↓
Buffer Text
↓
Throttle UI Updates
↓
Update Only Active Bubble
↓
Check Scroll Position
↓
Auto-Follow Only If Appropriate
The key principles are:
Do not rebuild everything
Do not scroll on every network chunk
Do not fight the user's scroll position
Do not reparse expensive Markdown unnecessarily
Do not forget cancellation
Do not couple widgets directly to provider APIs
When these principles are implemented correctly, even long AI responses can feel smooth on mobile devices.
Streaming should make your Flutter AI app feel faster.
It should not make your interface unstable.
Frequently Asked Questions
1. How do I stream AI responses in Flutter?
Expose the provider response as a Dart Stream, listen for text deltas and incrementally update a dedicated streaming-message state instead of waiting for the full response.
2. Does OpenAI support streaming responses?
Yes. OpenAI’s Responses API supports streaming and exposes incremental streaming events, including text deltas.
3. Does Gemini support streaming?
Yes. Gemini supports streamed generation so applications can process response chunks while generation is happening.
4. Does Claude support streaming?
Yes. Claude’s Messages API supports SSE-based streaming with stream: true.
5. Should I call setState for every AI token?
Usually not for a large production chat screen. Rapid updates can cause unnecessary rebuilds. Isolate the streaming widget and consider batching updates.
6. Is StreamBuilder good for AI streaming?
Yes, StreamBuilder is useful for reacting to stream snapshots, but performance still depends on how frequently the stream emits and how expensive the builder is.
7. Why does my Flutter AI chat lag while streaming?
Common causes include excessive rebuilds, repeatedly parsing Markdown, syntax highlighting, constantly recalculating layout and animating the scroll position on every chunk.
8. How often should the streaming UI update?
There is no universal value. A starting point around 30–100 ms can work well for many interfaces, but profile the actual application and device.
9. Why does my ListView jump while AI text is streaming?
The growing response changes content height. If you repeatedly call animateTo() during each update, scroll animations and layout changes can compete.
10. How do I stop automatic scrolling when the user scrolls upward?
Track the distance from the current scroll position to the bottom. Disable auto-follow when that distance crosses a threshold.
11. How can I automatically scroll when the user returns to the bottom?
Listen to the ScrollController; when the user is again within your bottom threshold, re-enable auto-follow.
12. Why should I use addPostFrameCallback when scrolling?
After changing message content, Flutter may need another frame to calculate its new height. Scrolling after that layout gives you the updated maxScrollExtent.
13. Should I use animateTo or jumpTo?
Use animateTo for meaningful navigation or new complete messages. During rapid streaming, throttled jumpTo or less frequent animations can prevent competing scroll animations.
14. Should I use ListView.builder for an AI chat?
Generally yes for potentially long histories because items are built lazily as needed.
15. Why is Markdown slow during streaming?
The entire growing Markdown string may be parsed repeatedly as each update arrives. Batching updates or rendering simpler text until completion can reduce the cost.
16. How do I add a Stop Generation button in Flutter?
Store the active StreamSubscription and cancel it when the user presses Stop. If your HTTP client or backend supports cancellation, propagate the cancellation there as well.
17. What should happen to partial AI text after Stop?
Usually preserve it. The user may already have read useful content. You can mark the message as stopped or incomplete.
18. How should I handle a network error halfway through a streamed response?
Preserve the partial response and offer options such as Continue or Regenerate. Avoid blindly appending a full automatic retry that may duplicate existing text.
19. Should OpenAI, Gemini and Claude have separate Flutter UI implementations?
Usually no. Normalize provider-specific streams into common application events such as AiTextDelta, AiStreamCompleted and AiStreamFailed.
20. What is the best architecture for smooth Flutter AI streaming?
A strong starting architecture is:
Provider
↓
Backend
↓
Repository
↓
Text/Event Stream
↓
Buffer
↓
Throttled Streaming State
↓
Isolated Message Bubble
↓
Smart ScrollController
This separation gives you significantly better control over performance, scrolling, cancellation and future AI-provider changes.
Official References
Flutter StreamBuilder
Flutter’s official API documentation for building widgets from Dart streams.
Flutter StreamBuilder Documentation
Flutter ScrollController
Official documentation covering scroll position, controllers, offsets and programmatic scrolling.
Flutter ScrollController Documentation
Flutter ListView
Official documentation for Flutter’s scrollable linear list and lazy ListView.builder constructor.