flutter
/

BLoC Performance Optimization: Best Practices for High-Performance Apps

Last Sync: Today

On this page

14
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Performance Optimization: Best Practices for High-Performance Apps

What is BLoC Performance?

BLoC performance refers to the efficiency of your Flutter app when using the BLoC pattern for state management. This includes minimizing unnecessary widget rebuilds, reducing the frequency of state emissions, and ensuring that event handlers execute quickly. Optimizing BLoC is crucial for maintaining smooth UI animations and a responsive user experience, especially in complex apps with frequent state changes.

Why Performance Matters in BLoC

  • Smooth UI – Frequent rebuilds can cause jank and dropped frames.
  • Battery Life – Unnecessary computations drain battery.
  • Scalability – As the app grows, performance issues become more visible.
  • User Experience – A sluggish app leads to poor reviews and user churn.
  • Complex Interactions – Real-time features like search, animations, and gestures demand efficiency.

Common Performance Pitfalls

  • Not using Equatable – Causes every state change to trigger a rebuild, even if the data hasn't changed.
  • Rebuilding entire widget trees – Using BlocBuilder at the root instead of selectively listening to parts of the state.
  • Large, monolithic states – When any part of the state changes, all listeners rebuild.
  • Expensive computations inside event handlers – Blocking the event loop and causing delays.
  • Missing event transformers – Processing every keystroke or tap without debouncing/throttling.
  • Overusing context.watch – Causes widgets to rebuild on every state change, even if they only need a small part.

Using Equatable for State Comparison

Equatable overrides == and hashCode so that state objects are considered equal if their properties match. This prevents BlocBuilder and BlocListener from rebuilding when the state is logically the same but a new instance is emitted.

DARTRead-only
1
@immutable
class MyState extends Equatable {
  final List<Item> items;
  final bool isLoading;
  final String? error;

  const MyState({required this.items, this.isLoading = false, this.error});

  @override
  List<Object?> get props => [items, isLoading, error];
}

// Without Equatable, emitting a new state with identical data would rebuild widgets.
// With Equatable, only actual changes cause rebuilds.

Selective Widget Rebuilding

Instead of using BlocBuilder at the top level, use BlocSelector or context.select to listen only to the parts of the state that matter to a specific widget.

DARTRead-only
1
// ❌ Rebuilds everything when state changes
BlocBuilder<MyBloc, MyState>(
  builder: (context, state) {
    return Column(
      children: [
        Text('Loading: ${state.isLoading}'),
        ...state.items.map((item) => ItemWidget(item)),
      ],
    );
  },
);

// ✅ Only rebuilds when isLoading changes
BlocSelector<MyBloc, MyState, bool>(
  selector: (state) => state.isLoading,
  builder: (context, isLoading) {
    return Text('Loading: $isLoading');
  },
);

// Using context.select in a Consumer widget
final isLoading = context.select((MyBloc bloc) => bloc.state.isLoading);

// For multiple fields, combine selectors
final (items, error) = context.select((MyBloc bloc) => (bloc.state.items, bloc.state.error));

Optimizing State Granularity

Keep your state classes as small as possible. Split a large state into multiple blocs or use nested states. For example, separate UI state from data state, or have separate blocs for different features.

DARTRead-only
1
// ❌ Large monolithic state
class DashboardState extends Equatable {
  final List<User> users;
  final List<Post> posts;
  final bool isLoadingUsers;
  final bool isLoadingPosts;
  final String? userError;
  final String? postError;
  // ...
}

// ✅ Split into multiple blocs
class UsersBloc extends Bloc<UsersEvent, UsersState> {...}
class PostsBloc extends Bloc<PostsEvent, PostsState> {...}
// Each bloc manages its own focused state, reducing rebuild scope.

Event Transformers for High-Frequency Events

Use event transformers like debounce and throttle to reduce the number of processed events. This is essential for search inputs, button spamming, and scrolling events.

DARTRead-only
1
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: debounce(const Duration(milliseconds: 300)),
    );
  }

  EventTransformer<Event> debounce<Event>(Duration duration) {
    return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
  }
}

// Without debounce, every keystroke would trigger an expensive search.

Offloading Heavy Computations

Avoid performing heavy synchronous work inside event handlers. Use isolates or delegate to a repository that can do async processing. Keep event handlers focused on state transitions.

DARTRead-only
1
class DataBloc extends Bloc<DataEvent, DataState> {
  final DataRepository repository;

  DataBloc(this.repository) : super(DataInitial()) {
    on<LoadData>((event, emit) async {
      emit(DataLoading());
      // ❌ Heavy computation inside BLoC
      // final result = heavyComputation(event.data);
      
      // ✅ Offload to repository
      final result = await repository.processData(event.data);
      emit(DataLoaded(result));
    });
  }
}

class DataRepository {
  Future<List<int>> processData(List<int> data) async {
    // Use compute() or Isolate for CPU-intensive work
    return await compute(heavyComputation, data);
  }
}

Using Async* with Yield Properly

When using async* in event handlers, be mindful of emitting multiple states. If you emit a loading state, then a success state, that's fine. But avoid emitting too many intermediate states unless necessary.

DARTRead-only
1
// Good: minimal state transitions
on<ProcessData>((event, emit) async* {
  emit(Loading());
  final result = await repository.process(event.data);
  emit(Success(result));
});

// Avoid: emitting too many granular states
// emit(Step1());
// await task1();
// emit(Step2()); // Often unnecessary and causes extra rebuilds

BlocProvider and MultiBlocProvider

Place BlocProvider as high as possible but not higher than necessary. Using MultiBlocProvider at the root is fine for global blocs. For feature-specific blocs, create them lazily using BlocProvider.value or BlocProvider in the route.

DARTRead-only
1
// ❌ Creating blocs unnecessarily for all routes
MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => LoginBloc()),
    BlocProvider(create: (_) => ProfileBloc()),
    // These are created even if not used
  ],
  child: MyApp(),
);

// ✅ Lazy creation, only when accessed
BlocProvider(
  create: (_) => LoginBloc(),
  lazy: true, // default
  child: LoginScreen(),
);

// Using BlocProvider.value to share existing instances
BlocProvider.value(
  value: existingBloc,
  child: SomeScreen(),
);

Performance Comparison: Techniques Impact

TechniqueImpact on PerformanceWhen to Use
EquatableHigh – prevents unnecessary rebuildsAlways use for all state classes
BlocSelector / context.selectHigh – reduces widget rebuild scopeWhenever a widget only depends on part of the state
Event Transformers (debounce/throttle)Medium-High – reduces event processingHigh-frequency events like search, button spam
Split large states into multiple blocsHigh – isolates rebuildsLarge, complex UI with independent features
Offload heavy computationsHigh – prevents UI jankCPU-intensive tasks like image processing, data parsing
Async* state emission optimizationLow-Medium – avoids extra rebuildsWhen you have many intermediate states

Best Practices

  • Always extend Equatable for states and events to enable proper equality checks.
  • Use BlocSelector or context.select to listen only to needed state properties.
  • Keep states immutable and small – prefer nested blocs for independent features.
  • Apply event transformers for high-frequency events (search, infinite scroll).
  • Move business logic to repositories – keep BLoCs light and focused on orchestration.
  • Profile your app – use Flutter DevTools to identify rebuilds and CPU usage.
  • Use const constructors for state classes when possible.
  • Avoid synchronous heavy work – use compute or isolates.

Common Mistakes

  • ❌ Not using Equatable – Leads to unnecessary rebuilds when states are equal.
  • ❌ Using BlocBuilder at the root of a large page – Rebuilds the entire page on any state change.
  • ❌ Creating blocs inside build methods – Causes memory leaks and performance degradation.
  • ❌ Storing large lists in a single state and modifying them frequently – Emitting new list instances causes rebuilds even if items didn't change.
  • ❌ Ignoring event transformers for search – Causes a network request on every keystroke.
  • ❌ Not disposing blocs – BlocProvider disposes automatically, but manually created blocs should be closed.

Conclusion

Optimizing BLoC performance is about being intentional with state granularity, using equality checks, selecting only what you need, and offloading heavy work. By following these best practices, you can build Flutter apps that are responsive, efficient, and maintainable. Remember to profile regularly and keep performance in mind during development, not as an afterthought.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BLoC Performance Demo',
      home: BlocProvider(
        create: (_) => CounterBloc(),
        child: CounterPage(),
      ),
    );
  }
}

// ---------- State ----------
class CounterState extends Equatable {
  final int value;
  final bool isLoading;

  const CounterState({this.value = 0, this.isLoading = false});

  CounterState copyWith({int? value, bool? isLoading}) {
    return CounterState(
      value: value ?? this.value,
      isLoading: isLoading ?? this.isLoading,
    );
  }

  @override
  List<Object?> get props => [value, isLoading];
}

// ---------- Events ----------
abstract class CounterEvent extends Equatable {
  const CounterEvent();
  @override
  List<Object?> get props => [];
}

class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}
class HeavyOperation extends CounterEvent {}

// ---------- BLoC ----------
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState()) {
    on<Increment>(_onIncrement);
    on<Decrement>(_onDecrement);
    on<HeavyOperation>(_onHeavyOperation);
  }

  void _onIncrement(Increment event, Emitter<CounterState> emit) {
    emit(state.copyWith(value: state.value + 1));
  }

  void _onDecrement(Decrement event, Emitter<CounterState> emit) {
    emit(state.copyWith(value: state.value - 1));
  }

  Future<void> _onHeavyOperation(HeavyOperation event, Emitter<CounterState> emit) async {
    emit(state.copyWith(isLoading: true));
    // Simulate heavy computation (in a real app, offload to isolate)
    await Future.delayed(Duration(seconds: 1));
    // Simulate result
    emit(state.copyWith(isLoading: false, value: state.value + 10));
  }
}

// ---------- UI ----------
class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Use BlocSelector to only listen to isLoading for the loading indicator
    return Scaffold(
      appBar: AppBar(title: Text('BLoC Performance')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // This widget rebuilds only when isLoading changes
            BlocSelector<CounterBloc, CounterState, bool>(
              selector: (state) => state.isLoading,
              builder: (context, isLoading) {
                if (isLoading) {
                  return CircularProgressIndicator();
                }
                return SizedBox.shrink();
              },
            ),
            SizedBox(height: 20),
            // This widget rebuilds only when value changes
            BlocSelector<CounterBloc, CounterState, int>(
              selector: (state) => state.value,
              builder: (context, value) {
                return Text(
                  '$value',
                  style: Theme.of(context).textTheme.headlineMedium,
                );
              },
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () => context.read<CounterBloc>().add(Increment()),
                  child: Icon(Icons.add),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () => context.read<CounterBloc>().add(Decrement()),
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () => context.read<CounterBloc>().add(HeavyOperation()),
                  child: Text('Heavy'),
                ),
              ],
            ),
            SizedBox(height: 20),
            Text(
              'Note: Only the specific widgets rebuild on changes.\nCheck console logs for rebuild count.',
              textAlign: TextAlign.center,
              style: TextStyle(fontSize: 12),
            ),
          ],
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

Which package helps prevent unnecessary rebuilds by enabling proper state equality?

A
flutter_bloc
B
equatable
C
provider
D
rxdart
Q2
of 4

What widget should you use to listen to only a part of the BLoC state?

A
BlocBuilder
B
BlocConsumer
C
BlocSelector
D
BlocProvider
Q3
of 4

Which technique is best for handling rapid search input changes?

A
Throttle
B
Debounce
C
Concurrent processing
D
Sequential processing
Q4
of 4

What is a common performance problem when using BLoC?

A
Using Equatable on states
B
Using BlocProvider at the root
C
Storing large states and rebuilding everything on any change
D
Using async* in event handlers

Frequently Asked Questions

Is Equatable mandatory for performance?

Technically no, but it's highly recommended. Without it, BlocBuilder and BlocListener will treat every new state instance as different, causing unnecessary rebuilds. Equatable fixes that with minimal overhead.

What's the difference between `BlocBuilder` and `BlocSelector`?

BlocBuilder rebuilds the entire widget whenever any part of the state changes. BlocSelector allows you to select a subset of the state and rebuilds only when that subset changes. Use BlocSelector for better performance.

How many blocs should I create?

There's no strict rule, but aim for one bloc per feature or per independent part of the UI. Avoid one giant bloc that manages everything. Separate concerns and keep blocs focused.

Can event transformers cause event loss?

Yes, debounce and throttle intentionally drop some events. This is by design to reduce workload. For critical actions like form submission, use a separate event type without a transformer.

How can I measure rebuilds in my app?

Use Flutter DevTools – open the Performance tab, record a profile, and look at the widget rebuild count. Also, you can add debug prints in build methods to see when they are called.

Does `BlocProvider` create blocs lazily?

Yes, by default create is called when the bloc is first accessed. You can set lazy: false to create it immediately, but lazy is preferred for performance.

Previous

bloc global error handler

Next

bloc rebuild optimization

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.