widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Fix Flutter Streaming Markdown Flicker in AI Chat

Fix Flutter Streaming Markdown Flicker in AI Chat

fluttermarkdownstreamingsseflutterflowai-chat

Fix Flutter Streaming Markdown Flicker in AI Chat

You wired up POST https://api.widgetchat.app/v1/chat/stream, you're reading data: lines off the SSE body, and tokens are landing in your Text widget beautifully. Then you swap Text for a markdown renderer and the chat bubble starts twitching: asterisks appear as literal asterisks for two frames and then vanish into bold, a ``` fence dumps a paragraph of raw code onto the screen before snapping into a grey box, and a table renders as a wall of pipes until the delimiter row arrives.

The instinct is to blame the renderer. It isn't the renderer. An unterminated markdown construct is a genuinely different document on every token, and any conforming parser is obligated to tell you so.

Why the flicker happens

Watch what the parser sees as three tokens land:

Buffer Parses to
Here is the fix:\n\n``` paragraph + paragraph starting with backticks
Here is the fix:\n\n```dart\nfinal x paragraph + unterminated fence → CommonMark closes it at EOF, so: code block
Here is the fix:\n\n```dart\nfinal x = 1;\n``` paragraph + closed code block

Each of those is a valid, different parse. flutter_markdown_plus re-parses the whole string and rebuilds the whole widget subtree on every one, which is why you see flutter_markdown re-render every token as visible tearing rather than as a smooth append. At 40 tokens/second on a 900-token answer that's ~900 full parses, ~900 syntax-highlight passes, and ~900 subtree teardowns.

So the fix has two halves, and you need both:

  1. Make consecutive prefixes parse the same. Speculatively close whatever is open before you hand the string to the parser.
  2. Make Flutter diff instead of tear down. Split into top-level blocks, key them by index, and hand back the identical widget instance for blocks whose source didn't change.

Part 2 without part 1 gets you stable-but-wrong output. Part 1 without part 2 gets you correct output that still re-parses your entire message every 25 ms.

Part 1: repair the document, then parse it

The repair rules, in priority order:

  • Unterminated fence wins over everything. If a ``` (or ~~~) is open, append a matching closer and stop — nothing inside a code block can be "unbalanced".
  • A half-typed fence (` or ``) on the last line gets dropped, so you never flash stray backticks.
  • Tables: a header row with no delimiter row yet renders as a pipe-filled paragraph. Synthesize | --- | --- | with the header's column count so the grid exists from row one.
  • Inline markers on the block currently being written: balance odd counts of `, **, __, ~~, *, _. Closing **bo into **bo** is right; hiding it would make the word appear unstyled and then re-style, which is the flicker you're trying to kill.
  • A marker run at the very end with no content yet (** alone) gets stripped instead of closed — **** is not what you want.
import 'package:markdown/markdown.dart' as md; // ^7.3.1

/// Repairs a partially-streamed markdown document so consecutive prefixes
/// parse to nearly the same tree, then splits it into top-level blocks.
class StreamingMarkdownBuffer {
  static final _fenceOpen = RegExp(r'^ {0,3}(`{3,}|~{3,})');
  static final _fenceClose = RegExp(r'^ {0,3}(`{3,}|~{3,})\s*$');
  static final _partialFence = RegExp(r'^ {0,3}([`~]{1,2})$');
  static final _listItem = RegExp(r'^ {0,3}([-*+]|\d+[.)])\s');
  static final _delimCell = RegExp(r'^\s*:?-+:?\s*$');
  static final _dangling = RegExp(r'(?:^|[^*_~`])([*_~`]{1,3})$');

  final StringBuffer _raw = StringBuffer();
  List<String> _blocks = const <String>[];

  /// Top-level blocks of the repaired document, in order.
  List<String> get blocks => _blocks;

  /// The untouched original — persist this when the stream ends.
  String get rawText => _raw.toString();

  void clear() {
    _raw.clear();
    _blocks = const <String>[];
  }

  /// Feed one SSE delta. Returns true if the block list actually changed.
  bool append(String chunk) {
    if (chunk.isEmpty) return false;
    _raw.write(chunk);
    return _recompute(_raw.toString());
  }

  /// Replace the whole document (FlutterFlow hands you accumulated text,
  /// not deltas). Same repair and diff path as [append].
  bool setText(String text) {
    _raw
      ..clear()
      ..write(text);
    return _recompute(text);
  }

  bool _recompute(String text) {
    final next = _split(_repair(text));
    if (_same(next, _blocks)) return false;
    _blocks = next;
    return true;
  }

  // ---- repair ------------------------------------------------------------

  String _repair(String text) {
    final lines = text.split('\n');

    // 1. An open fence makes every later token a different document.
    String? open;
    for (final line in lines) {
      if (open == null) {
        final m = _fenceOpen.firstMatch(line);
        if (m != null) open = m.group(1)!;
      } else {
        final run = _fenceClose.firstMatch(line)?.group(1);
        if (run != null && run[0] == open[0] && run.length >= open.length) {
          open = null;
        }
      }
    }
    if (open != null) return '$text\n$open'; // close it and stop

    // 2. A fence still being typed would show as stray backticks.
    if (lines.isNotEmpty && _partialFence.hasMatch(lines.last)) {
      lines.removeLast();
    }

    // 3. Table header with no delimiter row yet -> synthesize one.
    if (lines.isNotEmpty && lines.last.trimLeft().startsWith('|')) {
      var start = lines.length - 1;
      while (start > 0 && lines[start - 1].trimLeft().startsWith('|')) {
        start--;
      }
      final cols = _cellCount(lines[start]);
      final delim = '|${List.filled(cols, ' --- ').join('|')}|';
      if (start == lines.length - 1) {
        lines.add(delim);
      } else if (!_isDelimiterRow(lines[start + 1])) {
        lines[start + 1] = delim; // delimiter half-typed: |---|--
      }
    }

    // 4. Balance inline markers on the block still being written.
    var tailStart = 0;
    for (var i = lines.length - 1; i >= 0; i--) {
      if (lines[i].trim().isEmpty || _fenceClose.hasMatch(lines[i])) {
        tailStart = i + 1;
        break;
      }
    }
    final tail = lines.sublist(tailStart).join('\n');
    return [...lines.sublist(0, tailStart), _balanceInline(tail)].join('\n');
  }

  String _balanceInline(String tail) {
    var t = tail;
    final m = _dangling.firstMatch(t);
    if (m != null) {
      final stripped = t.substring(0, t.length - m.group(1)!.length);
      if (_closers(stripped).isEmpty) t = stripped; // opener with no content
    }
    return t + _closers(t).join();
  }

  List<String> _closers(String s) {
    final closers = <String>[];
    var work = s;

    // Inline code first: it suppresses every other marker inside it.
    if ('`'.allMatches(work).length.isOdd) closers.add('`');
    work = work.replaceAll(RegExp(r'`[^`]*`'), ' ').replaceAll('`', ' ');

    // snake_case identifiers are not emphasis.
    work = work.replaceAll(RegExp(r'(?<=\w)_(?=\w)'), ' ');
    for (final marker in const ['**', '__', '~~']) {
      if (marker.allMatches(work).length.isOdd) closers.add(marker);
      work = work.replaceAll(marker, ' ');
    }

    // Bullets are not emphasis either.
    work = work.replaceAll(RegExp(r'^\s*[*+-]\s', multiLine: true), '  ');
    for (final marker in const ['*', '_']) {
      if (marker.allMatches(work).length.isOdd) closers.add(marker);
      work = work.replaceAll(marker, ' ');
    }
    return closers.reversed.toList();
  }

  int _cellCount(String row) {
    var s = row.trim();
    if (s.startsWith('|')) s = s.substring(1);
    if (s.endsWith('|')) s = s.substring(0, s.length - 1);
    return s.split('|').length;
  }

  bool _isDelimiterRow(String row) {
    var s = row.trim();
    if (!s.contains('-')) return false;
    if (s.startsWith('|')) s = s.substring(1);
    if (s.endsWith('|')) s = s.substring(0, s.length - 1);
    return s.split('|').every(_delimCell.hasMatch);
  }

  // ---- split -------------------------------------------------------------

  List<String> _split(String text) {
    final blocks = <String>[];
    final current = <String>[];
    String? fence;

    void flush() {
      while (current.isNotEmpty && current.last.trim().isEmpty) {
        current.removeLast();
      }
      if (current.isNotEmpty) blocks.add(current.join('\n'));
      current.clear();
    }

    for (final line in text.split('\n')) {
      if (fence == null) {
        final m = _fenceOpen.firstMatch(line);
        if (m != null) {
          flush();
          fence = m.group(1)!;
          current.add(line);
        } else if (line.trim().isEmpty) {
          flush();
        } else {
          current.add(line);
        }
      } else {
        current.add(line);
        final run = _fenceClose.firstMatch(line)?.group(1);
        if (run != null && run[0] == fence[0] && run.length >= fence.length) {
          flush();
          fence = null;
        }
      }
    }
    flush();

    // A blank line inside a list is not a block boundary — splitting there
    // would restart ordered-list numbering at 1.
    final merged = <String>[];
    for (final b in blocks) {
      if (merged.isNotEmpty &&
          _listItem.hasMatch(merged.last) &&
          _listItem.hasMatch(b)) {
        merged[merged.length - 1] = '${merged.last}\n\n$b';
      } else {
        merged.add(b);
      }
    }
    return merged;
  }

  bool _same(List<String> a, List<String> b) {
    if (a.length != b.length) return false;
    for (var i = 0; i < a.length; i++) {
      if (a[i] != b[i]) return false;
    }
    return true;
  }
}

Part 2: stable keys and identical widget instances

Here's the part most "add a ValueKey" advice gets half-right. Keys alone don't stop the parse. What stops it is that Element.updateChild returns early when the new widget is identical (==) to the old one — the subtree is never rebuilt at all. So cache the MarkdownBody instance per block and only construct a new one when that block's source string changed. In a streaming answer that's exactly one block: the last.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; // ^1.0.12
import 'package:markdown/markdown.dart' as md;

class StreamingMarkdownView extends StatefulWidget {
  const StreamingMarkdownView({super.key, required this.chunks});

  final Stream<String> chunks;

  @override
  State<StreamingMarkdownView> createState() => _StreamingMarkdownViewState();
}

class _StreamingMarkdownViewState extends State<StreamingMarkdownView> {
  final _buffer = StreamingMarkdownBuffer();
  final _sources = <String>[];
  final _children = <Widget>[];

  StreamSubscription<String>? _sub;
  Timer? _coalesce;
  var _dirty = false;

  @override
  void initState() {
    super.initState();
    _sub = widget.chunks.listen(
      (chunk) {
        if (!_buffer.append(chunk)) return;
        _dirty = true;
        // One frame per ~50 ms, not one per token.
        _coalesce ??= Timer(const Duration(milliseconds: 50), () {
          _coalesce = null;
          if (!_dirty || !mounted) return;
          _dirty = false;
          setState(_syncChildren);
        });
      },
      onDone: () {
        if (mounted) setState(_syncChildren);
      },
    );
  }

  void _syncChildren() {
    final blocks = _buffer.blocks;
    for (var i = 0; i < blocks.length; i++) {
      // Same source -> reuse the same instance -> Flutter skips the subtree.
      if (i < _sources.length && _sources[i] == blocks[i]) continue;

      final child = MarkdownBody(
        key: ValueKey<int>(i),
        data: blocks[i],
        selectable: true,
        extensionSet: md.ExtensionSet.gitHubFlavored,
      );
      if (i < _sources.length) {
        _sources[i] = blocks[i];
        _children[i] = child;
      } else {
        _sources.add(blocks[i]);
        _children.add(child);
      }
    }
    if (blocks.length < _sources.length) {
      _sources.removeRange(blocks.length, _sources.length);
      _children.removeRange(blocks.length, _children.length);
    }
  }

  @override
  void dispose() {
    _coalesce?.cancel();
    _sub?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: _children,
      );
}

The ValueKey<int>(i) matters when a block's type changes at a given index — a paragraph that becomes a code block. Without it, Flutter matches children positionally by runtime type and can reuse the wrong element, which is the "code block briefly inherits the paragraph's text style" artifact.

Feeding it from WidgetChat's SSE stream

WidgetChat streams token-by-token over Server-Sent Events, so you need http.Client.send (not http.post, which buffers) and a LineSplitter — it correctly buffers partial lines across TCP chunks, and utf8.decoder carries multi-byte characters across chunk boundaries.

import 'dart:convert';
import 'package:http/http.dart' as http;

Stream<String> widgetChatTokens({
  required Map<String, dynamic> body,
  required Map<String, String> headers,
  http.Client? client,
}) async* {
  final c = client ?? http.Client();
  final req = http.Request(
    'POST',
    Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
  )
    ..headers.addAll({
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream',
      ...headers,
    })
    ..body = jsonEncode(body);

  final res = await c.send(req);
  if (res.statusCode != 200) {
    throw Exception('WidgetChat stream failed: ${res.statusCode}');
  }

  await for (final line
      in res.stream.transform(utf8.decoder).transform(const LineSplitter())) {
    if (!line.startsWith('data:')) continue; // skip id:/event:/keep-alive
    final payload = line.substring(5).trim();
    if (payload.isEmpty) continue;
    final token = _delta(payload);
    if (token != null && token.isNotEmpty) yield token;
  }
}

// Log one raw `data:` line from your own project first and match the key —
// don't guess at the payload shape.
String? _delta(String payload) {
  if (!payload.startsWith('{')) return payload; // plain-text delta
  try {
    final json = jsonDecode(payload);
    if (json is! Map) return payload;
    for (final key in const ['delta', 'text', 'content', 'token']) {
      final v = json[key];
      if (v is String) return v;
    }
    return null;
  } on FormatException {
    return payload;
  }
}

Flutter Web caveat: package:http's default BrowserClient is XHR-backed and does not stream — the response only arrives once it's complete, so your "streaming" chat silently becomes a single dump. Pass a fetch_client FetchClient as the client argument on web; it uses the Fetch API and gives you a real incremental Stream.

The FlutterFlow version

FlutterFlow can't pass a Dart Stream into a Custom Widget parameter, so split it in two:

Custom Action — runs the SSE loop and pushes accumulated text into App State, throttled so you don't trigger a global rebuild per token. Take the request body as a raw JSON string parameter so the action doesn't hardcode a payload shape:

// Custom Action: streamWidgetChatReply(String requestBody, String apiKey)
Future streamWidgetChatReply(String requestBody, String apiKey) async {
  FFAppState().update(() => FFAppState().streamedReply = '');

  final acc = StringBuffer();
  var lastPush = DateTime.now();

  await for (final token in widgetChatTokens(
    body: jsonDecode(requestBody) as Map<String, dynamic>,
    headers: {'Authorization': 'Bearer $apiKey'},
  )) {
    acc.write(token);
    if (DateTime.now().difference(lastPush).inMilliseconds >= 50) {
      lastPush = DateTime.now();
      final snapshot = acc.toString();
      FFAppState().update(() => FFAppState().streamedReply = snapshot);
    }
  }
  final done = acc.toString();
  FFAppState().update(() => FFAppState().streamedReply = done);
}

Custom Widget — takes a String parameter bound to FFAppState().streamedReply and uses setText (not append, because FlutterFlow hands you the accumulated string, not the delta):

class StreamingMarkdown extends StatefulWidget {
  const StreamingMarkdown({
    super.key,
    this.width,
    this.height,
    required this.markdownText,
  });

  final double? width;
  final double? height;
  final String markdownText;

  @override
  State<StreamingMarkdown> createState() => _StreamingMarkdownState();
}

class _StreamingMarkdownState extends State<StreamingMarkdown> {
  final _buffer = StreamingMarkdownBuffer();
  final _sources = <String>[];
  final _children = <Widget>[];

  @override
  void initState() {
    super.initState();
    _buffer.setText(widget.markdownText);
    _syncChildren();
  }

  @override
  void didUpdateWidget(StreamingMarkdown old) {
    super.didUpdateWidget(old);
    if (old.markdownText == widget.markdownText) return;
    if (_buffer.setText(widget.markdownText)) setState(_syncChildren);
  }

  // _syncChildren() is identical to the pure-Flutter version above.

  @override
  Widget build(BuildContext context) => SizedBox(
        width: widget.width,
        height: widget.height,
        child: SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: _children,
          ),
        ),
      );
}

In Custom Code → Pubspec Dependencies, add flutter_markdown_plus: ^1.0.12 and markdown: ^7.3.1. http already ships with FlutterFlow projects. Note that flutter_markdown itself was discontinued by Google in 2025 — flutter_markdown_plus, maintained by Foresight Mobile, is the designated successor, so don't pin the old one.

What this doesn't fix, and the off-the-shelf option

The repair pass is heuristic. Intraword _ and Setext headings (Title followed by ===) can still blink for one frame, and the synthesized table delimiter re-renders once if the real one carries alignment colons. Everything else — the fence flash, the raw asterisks, the pipe-wall — goes away.

If you'd rather not own this code, streamdown (0.1.1) implements the same two ideas — provisional rendering of unclosed constructs plus deterministic node keys — with an append-only AST, and its README benchmarks it at ~188× faster than re-parsing per chunk on 5 KB of markdown at 4-character increments. gpt_markdown (1.1.8) is a good drop-in renderer if you also need LaTeX. The buffer above is worth owning when you want the repair rules to be yours — for example, holding back a partial tool-call block, or rendering your own product cards from a fenced language tag.

Try WidgetChat free

WidgetChat is an AI support chatbot you embed in Flutter and FlutterFlow apps, answering from your own content. It streams token-by-token over SSE from POST /v1/chat/stream and integrates through a plain HTTP client or FlutterFlow custom action — no proprietary SDK — so the buffer above drops straight in. The same widget also does real-time voice: users tap the mic and talk to your assistant, with barge-in, live captions, and product cards on screen while it speaks, across iOS, Android, and web.

Try WidgetChat free and give your users a chat bubble that renders cleanly from the very first token.

flutter_markdown_plus is the maintained successor to Google's discontinued flutter_markdown package.

FlutterFlow Custom Widgets accept pubspec dependencies, which is how you add flutter_markdown_plus to a FlutterFlow project.

streamdown packages the same two ideas — provisional rendering and stable node keys — if you'd rather not own the buffer code.

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!