flutter
/

BLoC Caching Strategy: Local Cache, Offline-First & Invalidation

Last Sync: Today

On this page

13
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Caching Strategy: Local Cache, Offline-First & Invalidation

What is a Caching Strategy in BLoC?

A caching strategy in BLoC defines how your app stores and retrieves data to improve performance, reduce network calls, and enable offline functionality. It involves using in-memory caches (within BLoCs or repositories), persistent storage (Hive, SQLite, SharedPreferences), and smart invalidation rules. By combining BLoC's reactive state management with a caching layer, you can create apps that feel instant, work offline, and sync gracefully when connectivity returns.

Why Implement Caching?

  • Performance – Serve data instantly from cache instead of waiting for network.
  • Offline Support – Users can browse previously loaded content without internet.
  • Reduced Bandwidth – Fewer network requests save data and battery.
  • Better UX – Avoid loading spinners on every screen.
  • Scalability – Cache reduces load on backend services.

Caching Layers in BLoC

A typical caching architecture has three layers:

  • Memory Cache – Fastest, lives as long as the app runs (e.g., a map in a repository or bloc).
  • Persistent Cache – Stored on disk, survives app restarts (Hive, SQLite, SharedPreferences).
  • Network – The source of truth, fallback when cache is stale or missing.

Repository Pattern with Caching

Separate data fetching logic into a repository that handles cache checks and network requests. The BLoC only interacts with the repository, keeping caching transparent.

DARTRead-only
1
class PostRepository {
  final ApiClient api;
  final HiveInterface hive;

  PostRepository({required this.api, required this.hive});

  Future<List<Post>> getPosts({bool forceRefresh = false}) async {
    // 1. Check memory cache (if any)
    // 2. Check disk cache (if forceRefresh is false)
    // 3. Fetch from network, update caches, return
    // ...
  }
}

In-Memory Caching with BLoC

You can keep a simple in-memory cache inside your BLoC or a repository. This is perfect for transient data that doesn't need persistence.

DARTRead-only
1
class PostBloc extends Bloc<PostEvent, PostState> {
  final PostRepository repository;
  List<Post>? _cachedPosts; // in-memory cache

  PostBloc(this.repository) : super(PostInitial()) {
    on<FetchPosts>((event, emit) async {
      if (_cachedPosts != null && !event.forceRefresh) {
        emit(PostLoaded(_cachedPosts!));
        return;
      }
      emit(PostLoading());
      final posts = await repository.getPosts();
      _cachedPosts = posts;
      emit(PostLoaded(posts));
    });
  }
}

Persistent Caching with Hive

Hive is a lightweight key-value database perfect for caching. Here's a repository that uses Hive for persistent caching.

DARTRead-only
1
class PostRepository {
  final ApiClient api;
  final Box<Map> cacheBox;

  PostRepository({required this.api, required this.cacheBox});

  Future<List<Post>> getPosts({bool forceRefresh = false}) async {
    // Try disk cache
    if (!forceRefresh && cacheBox.containsKey('posts')) {
      final cached = cacheBox.get('posts') as List;
      return cached.map((json) => Post.fromJson(json)).toList();
    }

    // Fetch from network
    final posts = await api.fetchPosts();
    // Store in cache
    await cacheBox.put('posts', posts.map((p) => p.toJson()).toList());
    return posts;
  }
}

Offline-First Strategy with HydratedBloc

hydrated_bloc automatically persists your UI state. Combine it with a repository that reads from a local database first. This gives a true offline-first experience.

DARTRead-only
1
class TodoBloc extends HydratedBloc<TodoEvent, TodoState> {
  final TodoRepository repository;

  TodoBloc(this.repository) : super(TodoInitial()) {
    on<LoadTodos>((event, emit) async {
      // First, emit cached state (if any) – hydrated_bloc already restored from disk
      if (state is TodoLoaded && (state as TodoLoaded).todos.isNotEmpty) {
        // Already have cached data
      }
      // Then try to fetch fresh data
      final freshTodos = await repository.getTodos();
      emit(TodoLoaded(freshTodos)); // will be automatically persisted
    });
  }

  @override
  TodoState fromJson(Map<String, dynamic> json) => TodoState.fromJson(json);

  @override
  Map<String, dynamic> toJson(TodoState state) => state.toJson();
}

Cache Invalidation Strategies

Cached data becomes stale. Invalidation ensures users see up-to-date content. Common strategies:

  • Time-to-Live (TTL) – Cache expires after a set duration.
  • Manual Refresh – User pulls to refresh, which bypasses cache.
  • Version-based – Invalidate when app or API version changes.
  • Event-driven – Invalidate specific cache entries after mutations (e.g., after adding a post).
DARTRead-only
1
class PostRepository {
  Future<List<Post>> getPosts() async {
    final cached = await getCachedPosts();
    final timestamp = await getTimestamp();
    if (cached != null && DateTime.now().difference(timestamp).inMinutes < 5) {
      return cached; // still fresh
    }
    // else fetch fresh
    final fresh = await api.fetchPosts();
    await cachePosts(fresh);
    return fresh;
  }
}

Handling Mutations (Create, Update, Delete)

When a user creates, updates, or deletes data, you should update both the cache and the backend. Implement optimistic updates for better UX.

DARTRead-only
1
on<AddTodo>((event, emit) async {
  // Optimistic update: update state immediately
  final newList = List<Todo>.from(state.todos)..add(event.todo);
  emit(TodoLoaded(newList));
  try {
    // Actually save to backend
    await repository.addTodo(event.todo);
  } catch (e) {
    // Rollback on failure
    emit(TodoLoaded(state.todos)); // revert to previous
    // Show error
  }
});

Combining Multiple Caching Strategies

For optimal performance, combine memory, disk, and network:

DARTRead-only
1
class MultiLayerCacheRepository {
  List<Post>? _memoryCache;
  final Box _diskCache;

  Future<List<Post>> getPosts({bool forceRefresh = false}) async {
    // 1. Return memory cache if available and not forced
    if (_memoryCache != null && !forceRefresh) return _memoryCache!;

    // 2. Check disk cache
    final diskCached = _diskCache.get('posts');
    if (diskCached != null && !forceRefresh) {
      _memoryCache = (diskCached as List).map((j) => Post.fromJson(j)).toList();
      return _memoryCache!;
    }

    // 3. Fetch from network
    final fresh = await api.fetchPosts();
    // Update memory and disk
    _memoryCache = fresh;
    await _diskCache.put('posts', fresh.map((p) => p.toJson()).toList());
    return fresh;
  }
}

Best Practices

  • Cache only what you need – Avoid caching large binary data; use local files for images.
  • Set reasonable TTLs – Balance freshness and performance based on data volatility.
  • Invalidate after mutations – After POST/PUT/DELETE, clear related cache entries.
  • Use repository pattern – Keep caching logic separate from BLoC.
  • Handle offline scenarios – If cache is empty and offline, show an error with retry option.
  • Test caching behavior – Write tests that simulate network delays and offline conditions.
  • Use hydrated_bloc for UI state – Persist the entire state for seamless app restarts.
  • Clear cache on logout – Remove user-specific data when user signs out.

Common Mistakes

  • ❌ Caching user-sensitive data without encryption – Use secure storage for tokens and PII.
  • ❌ Not invalidating cache after mutations – Users see outdated data.
  • ❌ Over-caching – Storing huge lists that consume memory.
  • ❌ Blocking the UI with cache operations – Use async operations; Hive is fast but still async.
  • ❌ Not handling cache read/write errors – Fallback to network or empty state.
  • ❌ Using hydrated_bloc for large datasets – It's meant for UI state, not large collections; use a local database for that.

Conclusion

Implementing a caching strategy with BLoC elevates your app's performance and user experience. By combining in-memory caches, persistent storage, and smart invalidation, you can build apps that feel instant and work offline. The repository pattern keeps caching logic clean and testable, while BLoC provides reactive state updates. Start with a simple cache and evolve as your app's needs grow.

Try it yourself

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

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  await Hive.openBox('cache');
  runApp(MyApp());
}

// ---------- Models ----------
class Post extends Equatable {
  final int id;
  final String title;
  final String body;

  const Post({required this.id, required this.title, required this.body});

  Map<String, dynamic> toJson() => {'id': id, 'title': title, 'body': body};
  factory Post.fromJson(Map<String, dynamic> json) => Post(
    id: json['id'],
    title: json['title'],
    body: json['body'],
  );

  @override List<Object?> get props => [id, title, body];
}

// ---------- Repository with Caching ----------
class PostRepository {
  final Box cacheBox;

  PostRepository(this.cacheBox);

  Future<List<Post>> fetchPosts({bool forceRefresh = false}) async {
    // Check cache
    if (!forceRefresh && cacheBox.containsKey('posts')) {
      final cached = cacheBox.get('posts') as List;
      return cached.map((json) => Post.fromJson(json)).toList();
    }

    // Simulate network delay
    await Future.delayed(Duration(seconds: 1));
    final List<Post> fresh = List.generate(
      5,
      (i) => Post(
        id: i + 1,
        title: 'Post ${i + 1}',
        body: 'This is the body of post ${i + 1}.',
      ),
    );

    // Store in cache
    await cacheBox.put('posts', fresh.map((p) => p.toJson()).toList());
    return fresh;
  }
}

// ---------- BLoC ----------
class PostsState extends Equatable {
  final List<Post> posts;
  final bool isLoading;
  final bool isCached;
  final String? error;

  const PostsState({
    this.posts = const [],
    this.isLoading = false,
    this.isCached = false,
    this.error,
  });

  PostsState copyWith({
    List<Post>? posts,
    bool? isLoading,
    bool? isCached,
    String? error,
  }) {
    return PostsState(
      posts: posts ?? this.posts,
      isLoading: isLoading ?? this.isLoading,
      isCached: isCached ?? this.isCached,
      error: error ?? this.error,
    );
  }

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

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

class FetchPosts extends PostsEvent {
  final bool forceRefresh;
  const FetchPosts({this.forceRefresh = false});
  @override List<Object?> get props => [forceRefresh];
}

class PostsBloc extends Bloc<PostsEvent, PostsState> {
  final PostRepository repository;

  PostsBloc(this.repository) : super(const PostsState()) {
    on<FetchPosts>(_onFetchPosts);
  }

  Future<void> _onFetchPosts(FetchPosts event, Emitter<PostsState> emit) async {
    emit(state.copyWith(isLoading: true, error: null));
    try {
      final posts = await repository.fetchPosts(forceRefresh: event.forceRefresh);
      emit(state.copyWith(
        posts: posts,
        isLoading: false,
        isCached: !event.forceRefresh && repository.cacheBox.containsKey('posts'),
      ));
    } catch (e) {
      emit(state.copyWith(isLoading: false, error: e.toString()));
    }
  }
}

// ---------- UI ----------
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final box = Hive.box('cache');
    return MaterialApp(
      home: BlocProvider(
        create: (_) => PostsBloc(PostRepository(box))..add(FetchPosts()),
        child: PostsPage(),
      ),
    );
  }
}

class PostsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Caching Demo'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () => context.read<PostsBloc>().add(FetchPosts(forceRefresh: true)),
          ),
        ],
      ),
      body: BlocBuilder<PostsBloc, PostsState>(
        builder: (context, state) {
          if (state.isLoading && state.posts.isEmpty) {
            return Center(child: CircularProgressIndicator());
          }
          if (state.error != null) {
            return Center(child: Text('Error: ${state.error}'));
          }
          return Column(
            children: [
              if (state.isCached)
                Container(
                  width: double.infinity,
                  color: Colors.green.shade100,
                  padding: EdgeInsets.all(8),
                  child: Text('⚡ Showing cached data', textAlign: TextAlign.center),
                ),
              Expanded(
                child: ListView.builder(
                  itemCount: state.posts.length,
                  itemBuilder: (_, i) => ListTile(
                    title: Text(state.posts[i].title),
                    subtitle: Text(state.posts[i].body),
                  ),
                ),
              ),
              if (state.isLoading && state.posts.isNotEmpty)
                Padding(
                  padding: EdgeInsets.all(8),
                  child: CircularProgressIndicator(),
                ),
            ],
          );
        },
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

What is the main benefit of using a caching strategy?

A
Reduces app size
B
Improves performance and enables offline access
C
Automatically fixes bugs
D
Increases network calls
Q2
of 4

Which package is commonly used for fast, simple persistent storage in Flutter?

A
SQLite
B
Hive
C
SharedPreferences
D
All of the above
Q3
of 4

What does TTL stand for in caching?

A
Time to Live
B
Time to Load
C
Total Transfer Limit
D
Technical Test Layer
Q4
of 4

How do you ensure the user sees fresh data after a mutation (like adding a post)?

A
Invalidate the cache for that data after the mutation
B
Wait for the next app restart
C
Use a longer TTL
D
Always force refresh

Frequently Asked Questions

What's the difference between `hydrated_bloc` and a separate local database?

hydrated_bloc persists the entire BLoC state, which is great for UI state (e.g., current page, selected item). For large collections (e.g., thousands of posts), use a dedicated local database (Hive, SQLite) to avoid bloating the state and impacting performance.

How do I choose between Hive, SharedPreferences, and SQLite?

Use SharedPreferences for simple key-value pairs. Use Hive for structured data with quick reads/writes. Use SQLite for complex queries and relational data. Hive is often the best choice for caching because it's fast and easy to use.

How do I handle cache when the user logs out?

Clear any user-specific cache (e.g., user profile, posts). You can dispatch a ClearCache event to your repository or reset the BLoC state to initial.

What if the network is slow – how to show cached data while fetching fresh?

Emit the cached data immediately, then in the background fetch fresh data and update the state when it arrives. This gives an instant UI and then refreshes.

How do I test cache behavior in unit tests?

Mock the repository and simulate different conditions: cache hit, cache miss, network error. Use blocTest to verify that the correct states are emitted.

Previous

bloc dio integration

Next

bloc observer

Related Content

Need help?

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