You are implementing concurrency for a Flutter feature. Choose the pattern based on the skill argument, if one was given, or on the context of the operation.
Decision Framework
Pick the right tool for the workload:
| Workload | Pattern | Examples |
|---|---|---|
| I/O (network, storage) | async/await |
Dio API calls, SharedPreferences, Hive reads |
| CPU < 16 ms | async/await |
Small list transforms, formatting |
| One-time heavy CPU | Isolate.run() |
PDF receipt generation, large JSON parsing |
| Continuous background | Isolate.spawn() with ports |
Payment status polling, data sync |
Rule of thumb: if it does NOT block the main isolate for more than one frame (~16 ms), use async/await. Otherwise, offload to an isolate.
Pattern 1: Async in Cubits (default)
Best for: API calls, storage reads, form submissions.
Controllers follow the Loading → Success/Failure emission pattern:
class OrderController extends Cubit<OrderStatus> {
final OrderRepository _repository;
OrderController(this._repository) : super(const OrderInitial());
Future<void> loadOrders() async {
emit(const OrderLoading());
try {
final orders = await _repository.getOrders();
emit(OrderSuccess(orders));
} on DioException catch (e) {
emit(OrderFailure(e.message ?? 'Erro ao carregar pedidos'));
} catch (e) {
emit(OrderFailure(e.toString()));
}
}
}
Rules:
- Always emit
Loadingbefore the async call - Catch
DioExceptionseparately for HTTP errors - Never emit after the Cubit is closed (see Pitfalls section)
Pattern 2: Stream Management with RxDart
Best for: real-time data, debounced search, reactive pipelines.
class SearchController extends Cubit<SearchStatus> {
final SearchRepository _repository;
final _querySubject = BehaviorSubject<String>.seeded('');
StreamSubscription<String>? _querySub;
SearchController(this._repository) : super(const SearchInitial()) {
_querySub = _querySubject
.debounceTime(const Duration(milliseconds: 400))
.where((q) => q.length >= 2)
.distinct()
.listen(_performSearch);
}
void onQueryChanged(String query) => _querySubject.add(query);
Future<void> _performSearch(String query) async {
emit(const SearchLoading());
try {
final results = await _repository.search(query);
emit(SearchSuccess(results));
} catch (e) {
emit(SearchFailure(e.toString()));
}
}
@override
Future<void> close() {
_querySub?.cancel();
_querySubject.close();
return super.close();
}
}
Rules:
- Always dispose
BehaviorSubjectand cancelStreamSubscriptioninclose() - Use
debounceTimefor user input to avoid excessive API calls - Use
distinct()to skip duplicate emissions - Use
BehaviorSubject(notPublishSubject) when you need the latest value on subscription
Pattern 3: Isolate.run() for One-Time Heavy Work
Best for: PDF receipt generation, parsing large JSON payloads, image processing.
PDF Receipt Generation
class ReceiptController extends Cubit<ReceiptStatus> {
ReceiptController() : super(const ReceiptInitial());
Future<void> generateReceipt(Order order) async {
emit(const ReceiptLoading());
try {
// Offload PDF generation to a background isolate
final pdfBytes = await Isolate.run(() => _buildReceiptPdf(order));
emit(ReceiptSuccess(pdfBytes));
} catch (e) {
emit(ReceiptFailure('Erro ao gerar comprovante: $e'));
}
}
}
// Top-level or static function (required for Isolate.run)
Uint8List _buildReceiptPdf(Order order) {
final pdf = pw.Document();
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat(80 * PdfPageFormat.mm, double.infinity),
build: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Pedido #${order.id}', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 4),
...order.items.map((item) => pw.Text('${item.quantity}x ${item.name} - R\$ ${item.total.toStringAsFixed(2)}')),
pw.Divider(),
pw.Text('Total: R\$ ${order.total.toStringAsFixed(2)}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
],
),
),
);
return pdf.save();
}
Rules:
- The function passed to
Isolate.run()must be top-level or static (no closures over instance state) - All arguments must be sendable across isolate boundaries (primitives, Lists, Maps, typed data classes — no Cubits, no Dio instances)
Isolate.run()handles spawning and killing the isolate automatically
Large Data Processing
Future<void> processReport(List<Map<String, dynamic>> rawData) async {
emit(const ReportLoading());
try {
final processed = await Isolate.run(() => _aggregateReportData(rawData));
emit(ReportSuccess(processed));
} catch (e) {
emit(ReportFailure('Erro ao processar relatório: $e'));
}
}
// Top-level function
ReportData _aggregateReportData(List<Map<String, dynamic>> rawData) {
// Heavy aggregation, grouping, sorting — runs off the main isolate
final grouped = <String, double>{};
for (final entry in rawData) {
final category = entry['category'] as String;
final value = (entry['value'] as num).toDouble();
grouped[category] = (grouped[category] ?? 0) + value;
}
return ReportData(grouped);
}
Pattern 4: Isolate.spawn() for Continuous Background Work
Best for: payment status polling, background data sync.
class PaymentPollingService {
Isolate? _isolate;
ReceivePort? _receivePort;
StreamSubscription? _portSub;
Future<void> startPolling(String transactionId) async {
_receivePort = ReceivePort();
_isolate = await Isolate.spawn(
_pollPaymentStatus,
_PollingArgs(transactionId, _receivePort!.sendPort),
);
_portSub = _receivePort!.listen((message) {
if (message is PaymentStatusUpdate) {
// Forward to EventBus/Broker or Cubit
Broker.instance.fire(PaymentStatusEvent(message));
}
});
}
void stopPolling() {
_portSub?.cancel();
_receivePort?.close();
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
}
}
// Top-level function
void _pollPaymentStatus(_PollingArgs args) async {
while (true) {
await Future.delayed(const Duration(seconds: 3));
// Simulate checking payment status (no Dio here — use dart:io HttpClient or pass data via ports)
final status = await _checkStatus(args.transactionId);
args.sendPort.send(PaymentStatusUpdate(status));
if (status == 'approved' || status == 'declined') break;
}
}
class _PollingArgs {
final String transactionId;
final SendPort sendPort;
_PollingArgs(this.transactionId, this.sendPort);
}
Rules:
- Always provide a way to stop the isolate (
kill()+ close the port) - The spawned function cannot use objects from the main isolate (no Dio, no Cubit, no repos)
- Communicate via
SendPort/ReceivePortonly with sendable types - Clean up in the service's
dispose()or controller'sclose()
FutureBuilder vs BlocBuilder
With the Cubit pattern, prefer BlocBuilder over FutureBuilder.
FutureBuilder |
BlocBuilder |
|
|---|---|---|
| Use when | Quick prototype, one-off Future with no state | The default for anything with state |
| Re-triggers on rebuild | Yes (unless Future is cached) | No (reacts to state only) |
| Testable | Hard | Easy (mock Cubit, emit states) |
| Error handling | In builder snapshot.hasError |
In state class (FailureStatus) |
// PREFERRED
BlocBuilder<OrderController, OrderStatus>(
builder: (context, state) {
if (state is OrderLoading) return const CircularProgressIndicator();
if (state is OrderSuccess) return OrderListView(orders: state.orders);
if (state is OrderFailure) return ErrorWidget(message: state.message);
return const SizedBox.shrink();
},
)
// AVOID unless truly one-off and outside of a Cubit-managed feature
FutureBuilder<List<Order>>(
future: _future, // must be cached in initState, never inline
builder: (context, snapshot) { ... },
)
Cancellation
HTTP Requests with Dio CancelToken
class ProductController extends Cubit<ProductStatus> {
final ProductRepository _repository;
CancelToken? _cancelToken;
ProductController(this._repository) : super(const ProductInitial());
Future<void> loadProducts() async {
_cancelToken?.cancel('New request started');
_cancelToken = CancelToken();
emit(const ProductLoading());
try {
final products = await _repository.getProducts(cancelToken: _cancelToken!);
emit(ProductSuccess(products));
} on DioException catch (e) {
if (e.type == DioExceptionType.cancel) return; // silently ignore
emit(ProductFailure(e.message ?? 'Erro'));
}
}
@override
Future<void> close() {
_cancelToken?.cancel('Controller closed');
return super.close();
}
}
Stream Subscriptions
@override
Future<void> close() {
_subscription?.cancel(); // StreamSubscription
_subject.close(); // BehaviorSubject
_cancelToken?.cancel(); // Dio CancelToken
return super.close();
}
Rule: Every subscription, subject, and cancel token created in a controller must be cleaned up in close().
Common Pitfalls
1. Emit after close
// BAD — can throw StateError if Cubit was closed during the await
Future<void> load() async {
emit(const Loading());
final data = await _repo.fetch();
emit(Success(data)); // 💥 if navigated away
}
// GOOD — guard before emitting
Future<void> load() async {
emit(const Loading());
final data = await _repo.fetch();
if (isClosed) return;
emit(Success(data));
}
2. Uncancelled subscriptions
// BAD — leaks the subscription
class MyController extends Cubit<MyStatus> {
MyController(Stream<int> stream) : super(const MyInitial()) {
stream.listen((val) => emit(MyUpdated(val))); // never cancelled
}
}
// GOOD
class MyController extends Cubit<MyStatus> {
late final StreamSubscription<int> _sub;
MyController(Stream<int> stream) : super(const MyInitial()) {
_sub = stream.listen((val) => emit(MyUpdated(val)));
}
@override
Future<void> close() { _sub.cancel(); return super.close(); }
}
3. Blocking the main isolate
// BAD — 200ms of JSON parsing freezes the UI
final data = jsonDecode(hugeJsonString) as List;
// GOOD — offload to isolate
final data = await Isolate.run(() => jsonDecode(hugeJsonString) as List);
4. Passing non-sendable objects to isolates
// BAD — Dio is not sendable
await Isolate.run(() => dio.get('/endpoint')); // 💥 runtime error
// GOOD — fetch on main, process on isolate
final response = await dio.get('/endpoint');
final processed = await Isolate.run(() => _heavyParse(response.data));
5. FutureBuilder with inline Future
// BAD — re-creates the Future on every build
Widget build(BuildContext context) {
return FutureBuilder(
future: repository.load(), // called every rebuild!
builder: (ctx, snap) => ...,
);
}
// GOOD — cache the Future
late final _future = repository.load();
Widget build(BuildContext context) {
return FutureBuilder(future: _future, builder: (ctx, snap) => ...);
}