LogoSkills

SWR (Stale-While-Revalidate) Pattern

SWR strategy implementation guide for fast UI responsiveness when fetching data in projects.

SWR strategy implementation guide for fast UI responsiveness when fetching data in projects.

Overview#

The SWR strategy handles two scenarios:

ScenarioBehavior
No cache Show loading state -> Server request -> Display data
Cache exists Display cached data immediately -> Background server request -> Refresh data

Role by Architecture Layer#

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Page (Widget)                                                  โ”‚
โ”‚  - BlocBuilder๋กœ ์ƒํƒœ ๋ Œ๋”๋ง                                      โ”‚
โ”‚  - ๋กœ๋”ฉ/์—๋Ÿฌ/๋ฐ์ดํ„ฐ ์ƒํƒœ ์ฒ˜๋ฆฌ                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                           โ”‚ add(Event)
                           โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  BLoC                                                           โ”‚
โ”‚  - Stream ๊ตฌ๋… (emit.forEach)                                    โ”‚
โ”‚  - ์บ์‹œ โ†’ emit โ†’ ์„œ๋ฒ„ โ†’ emit (์ง€์—ฐ ์‘๋‹ต ์ฒ˜๋ฆฌ)                     โ”‚
โ”‚  - isClosed ์ฒดํฌ ํ•„์ˆ˜                                            โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                           โ”‚ call(params)
                           โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  UseCase (Stream ๊ธฐ๋ฐ˜)                                          โ”‚
โ”‚  - Stream < Either < Failure, T > >   ๋ฐ˜ํ™˜                              โ”‚
โ”‚  - async* + yield๋กœ ์ˆœ์ฐจ ๋ฐ์ดํ„ฐ ๋ฐฉ์ถœ                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                           โ”‚ getWithCache(params)
                           โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Repository                                                     โ”‚
โ”‚  - ์บ์‹œ ์กฐํšŒ โ†’ yield (์บ์‹œ ๋ฐ์ดํ„ฐ)                                โ”‚
โ”‚  - ์„œ๋ฒ„ ์š”์ฒญ โ†’ yield (์„œ๋ฒ„ ๋ฐ์ดํ„ฐ)                                โ”‚
โ”‚  - ์บ์‹œ ๊ฐฑ์‹                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

1. Repository Pattern (Cache + Server Sequential Return)#

Stream-based Repository Method#

// repository_interface.dart
abstract class IBookRepository {
  /// SWR pattern: cache -> server sequential return
  Stream<Either<Failure, List<Book>>> getBooksWithCache({
    required int categoryId,
    int? limit,
  });

  /// Before Future ํŒจํ„ด (๋‹จ์ˆœ ์„œ๋ฒ„ ํ˜ธ์ถœ)
  Future<Either<Failure, List<Book>>> getBooks({
    required int categoryId,
    int? limit,
  });
}

Repository Implementation#

// book_repository.dart
class BookRepository implements IBookRepository {
  const BookRepository(this._client, this._cacheService);

  final ApiClient _client;
  final BookCacheService _cacheService;

  @override
  Stream<Either<Failure, List<Book>>> getBooksWithCache({
    required int categoryId,
    int? limit,
  }) async* {
    final cacheKey = 'books_$categoryId';

    // 1. Check and emit cached data (if available)
    final cachedBooks = _cacheService.get(cacheKey);
    if (cachedBooks != null && cachedBooks.isNotEmpty) {
      yield right(cachedBooks);
    }

    // 2. Server request
    try {
      final serverBooks = await _client.book.getBooks(
        categoryId: categoryId,
        limit: limit,
      );

      // 3. Cache refresh
      _cacheService.set(cacheKey, serverBooks);

      // 4. Emit server data
      yield right(serverBooks);
    } on Exception catch (error, stackTrace) {
      Log.e('๋„์„œ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ', error: error, stackTrace: stackTrace);

      // Ignore error if cache existed (cached data already displayed)
      if (cachedBooks == null || cachedBooks.isEmpty) {
        yield left(BookLoadFailure(message: error.toString()));
      }
    }
  }
}

2. UseCase Pattern (Stream-based)#

StreamUseCase Abstract Class#

// stream_usecase.dart
/// Stream ๊ธฐ๋ฐ˜ UseCase ์ธํ„ฐํŽ˜์ด์Šค (SWR ํŒจํ„ด์šฉ)
///
/// ์บ์‹œ ๋ฐ์ดํ„ฐ์™€ ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ๋ฐฉ์ถœํ•ฉ๋‹ˆ๋‹ค.
abstract class StreamUseCase<T, Params, Repo> {
  /// Repository instance
  Repo get repo;

  /// Execute UseCase (returns Stream)
  ///
  /// On success, sequentially emits data of type [T],
  /// on failure, emits error of type [Failure].
  Stream<Either<Failure, T>> call(Params param);
}

/// Stream UseCase requiring authentication
abstract class AuthRequiredStreamUseCase<T, Params, Repo>
    extends StreamUseCase<T, Params, Repo>
    with GlobalAuthMixin {
  String get featureName;

  Stream<Either<Failure, T>> executeBusinessLogic(Params param);

  @override
  Stream<Either<Failure, T>> call(Params param) async* {
    // Auth check
    final isAuthenticated = await checkAuthentication();
    if (!isAuthenticated) {
      yield left(AuthenticationFailure(message: '$featureName: ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'));
      return;
    }

    // Execute business logic (Stream)
    await for (final result in executeBusinessLogic(param)) {
      yield result;
    }
  }
}

UseCase Implementation Example#

// get_books_with_cache_usecase.dart
class GetBooksWithCacheUseCase
    implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
  const GetBooksWithCacheUseCase();

  @override
  IBookRepository get repo => getIt<IBookRepository>();

  @override
  Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
    return repo.getBooksWithCache(
      categoryId: params.categoryId,
      limit: params.limit,
    );
  }
}

3. BLoC Pattern (Stream Subscription)#

// book_bloc.dart
class BookBloc extends Bloc<BookEvent, BookState> {
  BookBloc(this._getBooksWithCacheUseCase) : super(const BookState()) {
    on<_LoadBooks>(_onLoadBooks);
  }

  final GetBooksWithCacheUseCase _getBooksWithCacheUseCase;

  Future<void> _onLoadBooks(
    _LoadBooks event,
    Emitter<BookState> emit,
  ) async {
    // Show loading state only when no cache
    if (state.books.isEmpty) {
      emit(state.copyWith(status: const BookStatusLoading()));
    }

    // โœ… Subscribe to Stream via emit.forEach
    await emit.forEach<Either<Failure, List<Book>>>(
      _getBooksWithCacheUseCase(
        GetBooksParams(categoryId: event.categoryId),
      ),
      onData: (result) {
        return result.fold(
          (failure) => state.copyWith(
            status: BookStatusError(failure.toString()),
          ),
          (books) => state.copyWith(
            status: const BookStatusLoaded(),
            books: books,
            lastUpdated: DateTime.now(),
          ),
        );
      },
      onError: (error, stackTrace) {
        Log.e('๋„์„œ ๋ชฉ๋ก ๋กœ๋“œ ์‹คํŒจ', error: error, stackTrace: stackTrace);
        return state.copyWith(
          status: BookStatusError(error.toString()),
        );
      },
    );
  }
}

Manual Stream Subscription (Alternative)#

Future<void> _onLoadBooks(
  _LoadBooks event,
  Emitter<BookState> emit,
) async {
  if (state.books.isEmpty) {
    emit(state.copyWith(status: const BookStatusLoading()));
  }

  // โœ… Traverse Stream with await for
  await for (final result in _getBooksWithCacheUseCase(
    GetBooksParams(categoryId: event.categoryId),
  )) {
    // โš ๏ธ Required: isClosed check
    if (isClosed) return;

    emit(result.fold(
      (failure) => state.copyWith(
        status: BookStatusError(failure.toString()),
      ),
      (books) => state.copyWith(
        status: const BookStatusLoaded(),
        books: books,
        lastUpdated: DateTime.now(),
      ),
    ));
  }
}

4. State Design (SWR Compatible)#

State Class#

// book_state.dart
@freezed
class BookState with _$BookState {
  const factory BookState({
    @Default(BookStatusInitial()) BookStatus status,
    @Default([]) List<Book> books,
    DateTime? lastUpdated, // ์บ์‹œ/์„œ๋ฒ„ ๊ตฌ๋ถ„์šฉ
  }) = _BookState;
}

@freezed
sealed class BookStatus with _$BookStatus {
  const factory BookStatus.initial() = BookStatusInitial;
  const factory BookStatus.loading() = BookStatusLoading;
  const factory BookStatus.loaded() = BookStatusLoaded;
  const factory BookStatus.error(String message) = BookStatusError;
  const factory BookStatus.refreshing() = BookStatusRefreshing; // โœ… ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ฐฑ์‹  in progress
}

Cache/Server Data Distinction State#

// ๋” ์ •๊ตํ•œ ์ƒํƒœ ๊ด€๋ฆฌ
@freezed
sealed class BookStatus with _$BookStatus {
  const factory BookStatus.initial() = BookStatusInitial;
  const factory BookStatus.loading() = BookStatusLoading;
  const factory BookStatus.cachedData() = BookStatusCachedData;  // โœ… ์บ์‹œ ๋ฐ์ดํ„ฐ ํ‘œ์‹œ in progress
  const factory BookStatus.refreshing() = BookStatusRefreshing;  // โœ… ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ฐฑ์‹  in progress
  const factory BookStatus.freshData() = BookStatusFreshData;    // โœ… ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹ ๋จ
  const factory BookStatus.error(String message) = BookStatusError;
}

5. UI Pattern (Page/Widget)#

Using BlocBuilder#

// book_list_page.dart
class BookListPage extends StatelessWidget {
  const BookListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<BookBloc, BookState>(
      buildWhen: (prev, curr) =>
          prev.status != curr.status ||
          prev.books.length != curr.books.length || // โš ๏ธ ๋ฆฌ์ŠคํŠธ๋Š” length๋กœ ๋น„๊ต
          prev.lastUpdated != curr.lastUpdated,
      builder: (context, state) {
        return switch (state.status) {
          BookStatusInitial() ||
          BookStatusLoading() when state.books.isEmpty =>
            const BookListSkeleton(), // ๋กœ๋”ฉ ์Šค์ผˆ๋ ˆํ†ค

          BookStatusCachedData() ||
          BookStatusRefreshing() =>
            BookListView(
              books: state.books,
              isRefreshing: state.status is BookStatusRefreshing,
            ),

          BookStatusLoaded() ||
          BookStatusFreshData() =>
            BookListView(books: state.books),

          BookStatusError(:final message) when state.books.isEmpty =>
            ErrorView(message: message),

          BookStatusError() =>
            BookListView(
              books: state.books,
              errorMessage: (state.status as BookStatusError).message,
            ),

          _ => BookListView(books: state.books),
        };
      },
    );
  }
}

Background Refresh Indicator#

class BookListView extends StatelessWidget {
  const BookListView({
    required this.books,
    this.isRefreshing = false,
    this.errorMessage,
    super.key,
  });

  final List<Book> books;
  final bool isRefreshing;
  final String? errorMessage;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ฐฑ์‹  in progress ํ‘œ์‹œ
        if (isRefreshing)
          const LinearProgressIndicator(),

        // ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ (๋ฐ์ดํ„ฐ๋Š” ์žˆ์ง€๋งŒ ๊ฐฑ์‹  ์‹คํŒจ)
        if (errorMessage != null)
          ErrorBanner(message: errorMessage!),

        // ๋ฐ์ดํ„ฐ ๋ชฉ๋ก
        Expanded(
          child: ListView.builder(
            itemCount: books.length,
            itemBuilder: (context, index) => BookTile(book: books[index]),
          ),
        ),
      ],
    );
  }
}

6. Cache Service Pattern#

Memory Cache Service#

// book_cache_service.dart
@singleton
class BookCacheService {
  final _cache = <String, List<Book>>{};
  final _timestamps = <String, DateTime>{};

  /// Cache validity duration (5 minutes)
  static const _cacheDuration = Duration(minutes: 5);

  List<Book>? get(String key) {
    final timestamp = _timestamps[key];
    if (timestamp == null) return null;

    // Cache expiration check
    if (DateTime.now().difference(timestamp) > _cacheDuration) {
      _cache.remove(key);
      _timestamps.remove(key);
      return null;
    }

    return _cache[key];
  }

  void set(String key, List<Book> books) {
    _cache[key] = books;
    _timestamps[key] = DateTime.now();
  }

  void invalidate(String key) {
    _cache.remove(key);
    _timestamps.remove(key);
  }

  void invalidateAll() {
    _cache.clear();
    _timestamps.clear();
  }
}

7. Migration Guide#

Converting from Future-based to Stream-based#

// Before: Future ๊ธฐ๋ฐ˜
class GetBooksUseCase implements UseCase<List<Book>, GetBooksParams, IBookRepository> {
  @override
  Future<Either<Failure, List<Book>>> call(GetBooksParams params) async {
    return repo.getBooks(categoryId: params.categoryId);
  }
}

// After: Stream ๊ธฐ๋ฐ˜ (SWR)
class GetBooksWithCacheUseCase
    implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
  @override
  Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
    return repo.getBooksWithCache(categoryId: params.categoryId);
  }
}

BLoC Migration#

// Before: Future ๊ธฐ๋ฐ˜
Future<void> _onLoadBooks(_LoadBooks event, Emitter<BookState> emit) async {
  emit(state.copyWith(status: const BookStatusLoading()));

  final result = await _getBooksUseCase(params);
  if (isClosed) return;

  emit(result.fold(
    (failure) => state.copyWith(status: BookStatusError(failure.toString())),
    (books) => state.copyWith(status: const BookStatusLoaded(), books: books),
  ));
}

// After: Stream ๊ธฐ๋ฐ˜ (SWR)
Future<void> _onLoadBooks(_LoadBooks event, Emitter<BookState> emit) async {
  if (state.books.isEmpty) {
    emit(state.copyWith(status: const BookStatusLoading()));
  }

  await emit.forEach<Either<Failure, List<Book>>>(
    _getBooksWithCacheUseCase(params),
    onData: (result) => result.fold(
      (failure) => state.copyWith(status: BookStatusError(failure.toString())),
      (books) => state.copyWith(
        status: const BookStatusLoaded(),
        books: books,
        lastUpdated: DateTime.now(),
      ),
    ),
  );
}

8. Anti-patterns (When Consuming SWR Stream in BLoC)#

โŒ .last on SWR Stream โ€” Infinite wait (Critical)#

// โŒ WRONG: SWR Stream is created by SwrStrategyImpl.watchStream() using
// StreamController which is NOT closed until subscription cancel.
// .last awaits Stream close โ†’ handler waits forever โ†’ state never emits.
final result = await _useCases.getBooks(params).last;

Critical bug: SwrStrategyImpl.watchStream() returns a StreamController.stream that is closed only via onCancel. Stream.last requires close to return a value, so the handler hangs indefinitely and the page stays in loading state forever. Tests may pass because Stream.value(...) (single-value) auto-closes โ€” masking the bug.

Real cases:

  • PR #5465 / #5466 โ€” _onLoadLikedBooks, _onLoadMoreLikedBooks, _onLoadMoreMyBooks ๋„์„œ ๋ชฉ๋ก ๋ฏธํ‘œ์‹œ
  • PR #5467 โ€” console_book_list_bloc await for ํŒจํ„ด, console_book_registration_bloc 4๊ณณ await for (handler ๋ฏธ์ข…๋ฃŒ)

Recovery test pattern (close๋˜์ง€ ์•Š๋Š” stream์—์„œ๋„ ๋™์ž‘ ๊ฒ€์ฆ):

blocTest<MyBloc, MyState>(
  'close๋˜์ง€ ์•Š๋Š” stream์—์„œ๋„ ์ฒซ emission์œผ๋กœ ํ‘œ์‹œ๋˜์–ด์•ผ ํ•œ๋‹ค',
  setUp: () {
    final controller = StreamController<Either<Failure, T>>();
    addTearDown(controller.close);
    controller.add(Right(testData));
    when(() => mockUseCase(any())).thenAnswer((_) => controller.stream);
  },
  // ...
);

โš ๏ธ .first on SWR Stream โ€” Acceptable when cache invalidation is guaranteed#

// โš ๏ธ Conditional: .first only collects first emission (cache or server)
final result = await _useCases.getUserInfo(const NoParams()).first;

Trade-off: Loses the "background refresh" part of SWR โ€” only the first emission is consumed. The second emission (network result after cache hit) is missed because the stream is no longer awaited.

When .first is acceptable:

  1. Cache invalidation is guaranteed by upstream events (e.g., DomainEventBus) โ€” first emission becomes a fresh network result on cache miss
  2. Async transform between emissions is required (e.g., BookLike โ†’ Book per item) โ€” emit.forEach cannot await inside onData
  3. Pagination Load More โ€” see Exception below

When .first is wrong:

  • Cache TTL is long and no invalidation channel exists โ†’ user sees stale data until TTL expires
  • A simple list fetch where emit.forEach works fine

Real cases:

  • PR #4585 โ€” 10 calls in my_page_bloc using .first lost background refresh
  • PR #5465โ€“5467 โ€” .first is the correct fix when SWR stream doesn't close (only choice that doesn't hang)
  • PR #5471 โ€” Combined with DomainEventBus cache invalidation, .first works correctly because invalidated cache forces fresh fetch on next emission

โŒ Direct .listen usage - StreamSubscription management burden#

// โŒ WRONG: Direct listen inside BLoC -> requires subscription management
final subscription = _useCases.getBooks(params).listen(
  (result) => add(BooksUpdated(result)),
);

Problem: Missing subscription cancellation on close() causes memory leaks. emit.forEach automatically manages subscriptions.

Exception: .listen is appropriate for non-SWR WebSocket/SSE stream subscriptions like real-time chat.

โŒ await for on SWR Stream โ€” Handler hangs#

// โŒ WRONG: SWR Stream doesn't close โ†’ await for loop never exits
await for (final result in _useCases.getBooks(params)) {
  if (isClosed) return;
  result.fold(...);
}

Problem: Same root cause as .last โ€” SwrStrategyImpl.watchStream() doesn't close. await for loop awaits next emission forever, so the BLoC handler never returns. This causes:

  • Memory leaks (subscription never cancelled)
  • Next event processing delay (depending on transformer, sequential transformer queues new events)
  • Repeated work on every emission (e.g., publisher/category lookup runs twice)

Recovery options:

  1. .first if cache invalidation is guaranteed (preferred, simplest)
  2. emit.forEach with restartable() transformer โ€” emitter done automatically cancels subscription

Real case: PR #5467 โ€” console_book_list_bloc._loadBooksFromStream, console_book_registration_bloc 4 handlers

โœ… Correct pattern - emit.forEach + restartable()#

// โœ… CORRECT: Subscribe to SWR Stream via emit.forEach
on<_LoadBooks>(_onLoadBooks, transformer: restartable());

Future<void> _onLoadBooks(_LoadBooks event, Emitter<State> emit) async {
  if (state.books.isEmpty) {
    emit(state.copyWith(status: const Loading()));
  }

  await emit.forEach<Either<Failure, List<Book>>>(
    _useCases.getBooks(params),
    onData: (result) => result.fold(
      (failure) => state.copyWith(status: Error(failure)),
      (books) => state.copyWith(status: const Loaded(), books: books),
    ),
    onError: (_, _) => state.copyWith(status: const Error('๋กœ๋“œ ์‹คํŒจ')),
  );
}

Exception for Pagination "Load More"#

.last is appropriate for Load More (append):

  • Since SWR cache key doesn't include offset, cached data is previous page data
  • Subscribing with emit.forEach would duplicate-append cache (previous page) + server (new page) data
// โœ… Keep .last for Load More
final result = await _useCases.getBooks(
  PaginationParams(limit: 20, offset: currentOffset),
).last;

// Append after deduplication
final uniqueNew = newBooks.where((b) => !existingIds.contains(b.id)).toList();
emit(state.copyWith(books: [...state.books, ...uniqueNew]));

9. Cross-Module Mutation Cache Invalidation#

The Problem#

SWR caches are scoped per module. A mutation in one module (e.g., store::PurchaseBookUsecase) often invalidates caches in other modules (e.g., my_library::purchasedBooks, my_page::userWalletInfo). Without explicit invalidation, the consumer module sees stale data until cache TTL expires.

Example failure scenario:

  1. User purchases book in store (10-min CachePolicies.standard TTL)
  2. Within 10 min, user enters my_library
  3. SWR strategy: cache hit โ†’ no network fetch โ†’ stale data, purchased book not visible

Fix path: invalidate the consumer module's cache when the producer mutation succeeds.

โŒ Anti-pattern: bootstrap callback registration (accumulates)#

// app/kobic/lib/bootstrap.dart - new mutation pollutes bootstrap
// (ServiceLocator: package/core/lib/src/app/service_locator.dart)
ServiceLocator.instance.registerSingleton<BookLikeChangedCallback>(...);
ServiceLocator.instance.registerSingleton<BookPurchasedCallback>(...);
ServiceLocator.instance.registerSingleton<BookReviewedCallback>(...);
// keeps growing as new mutations are added
IssueWhy
SRP violationBootstrap takes responsibility for all domain cache invalidation
Low scalabilityEvery new mutation requires bootstrap edit
Low visibility Reading my_library alone doesn't reveal which mutations it reacts to
Tight coupling Bootstrap must import all related modules ( my_library , my_page , store , โ€ฆ)

Define domain events in package:core, register a single DomainEventBus in bootstrap, and let each module subscribe to events that affect its own caches.

// package:core - domain events + bus
abstract class DomainEvent {
  const DomainEvent();
}

@immutable
final class BookPurchasedEvent extends DomainEvent {
  const BookPurchasedEvent({required this.bookId});
  final int bookId;
}

abstract class DomainEventBus {
  void publish<T extends DomainEvent>(T event);
  StreamSubscription<T> subscribe<T extends DomainEvent>(
    void Function(T event) handler,
  );
  Future<void> dispose();
}

class InMemoryDomainEventBus implements DomainEventBus {
  // broadcast StreamController + per-handler try/catch isolation
}
// app/kobic/lib/bootstrap.dart - register ONCE, no growth
// (console: app/kobic_console/lib/bootstrap.dart;
//  ServiceLocator: package/core/lib/src/app/service_locator.dart)
ServiceLocator.instance.registerSingleton<DomainEventBus>(
  InMemoryDomainEventBus(),
);
registerMyLibraryModule();
registerMyPageModule();
// feature/store - publisher
class PurchaseBookUsecase {
  Future<void> call(...) async {
    if (result.isRight()) {
      try {
        if (locator.isRegistered<DomainEventBus>()) {
          locator.get<DomainEventBus>()
              .publish(BookPurchasedEvent(bookId: bookId));
        }
      } on Object catch (error, stackTrace) {
        Log.w('โš ๏ธ event publish failed (isolated)', error: error);
      }
    }
  }
}
// feature/my_library - subscriber owns its cache
void registerMyLibraryModule() {
  final eventBus = ServiceLocator.instance.get<DomainEventBus>();

  // ignore: avoid-unassigned-stream-subscriptions
  eventBus.subscribe<BookPurchasedEvent>((event) {
    unawaited(MyLibraryCacheInvalidator.fromDefault().invalidateAfterPurchase());
  });

  // ignore: avoid-unassigned-stream-subscriptions
  eventBus.subscribe<BookLikeChangedEvent>((event) {
    unawaited(MyLibraryCacheInvalidator.fromDefault().invalidateAfterToggleLike());
  });
}

Why this works#

PropertyCallback patternEventBus pattern
Bootstrap responsibility All mutation โ†’ module mappings Register bus once + call module init
SRPโŒ bootstrap owns all domainsโœ… each module owns its cache
Adding a mutationEdit bootstrap + moduleModule-only change
Producer/consumer couplingLoose via typedefLoose via event type
VisibilityBootstrap reveals mappingsModule reveals its own subscriptions
Failure isolationCaller's responsibilityBuilt-in per-handler try/catch

Implementation tips#

  1. @immutable on event classes โ€” events should be value objects
  2. Per-handler try/catch in the bus implementation โ€” one subscriber's exception must not break others or the publisher
  3. isRegistered<T>() check in publishers โ€” register-failure should not cascade into mutation failure
  4. unawaited() inside subscribers โ€” invalidation is fire-and-forget
  5. avoid-unassigned-stream-subscriptions lint suppression with comment โ€” global subscriptions persist for app lifetime by design
  6. abstract, not sealed for DomainEvent โ€” allows feature modules to define their own events without library lock-in
  7. Test pattern: subscribe โ†’ publish โ†’ await Future<void>.delayed(Duration.zero) โ†’ assert handler called

Real cases#

  • PR #5471 โ€” Migrated BookLikeChangedCallback and BookPurchasedCallback to DomainEventBus
  • PR #5469 (superseded by #5471) โ€” Added BookPurchasedCallback for cross-module invalidation
  • PR #5027 โ€” Original BookLikeChangedCallback pattern that revealed the SRP issue

10. Checklist#

SWR Implementation Checklist#

  • Use async* + yield pattern in Repository
  • UseCase returns Stream<Either<Failure, T>>
  • Use emit.forEach in BLoC for simple list fetch (preferred)
  • Apply restartable() transformer to events using emit.forEach
  • Skip loading state when cache data exists
  • Specify onError callback
  • Add lastUpdated or data source distinction field to State

Anti-pattern Avoidance Checklist#

  • NEVER use .last on SWR Stream (infinite wait โ€” stream doesn't close)
  • NEVER use await for on SWR Stream (handler hangs โ€” same root cause)
  • Use .first only when (a) cache invalidation is guaranteed, OR (b) async transform is needed inside, OR (c) Pagination Load More
  • Test with non-auto-closing StreamController.broadcast() to verify no infinite wait โ€” Stream.value(...) masks the bug

Cross-Module Mutation Checklist#

  • Identify which consumer modules' caches are affected by the mutation
  • Use DomainEventBus (NOT bootstrap callback registration)
  • Define event in package:core with @immutable
  • Publisher: eventBus.publish(...) inside try/catch with isRegistered<DomainEventBus>() check
  • Subscriber: register subscription inside the module's registerXxxModule()
  • Add cache invalidator method (e.g., invalidateAfterXxx()) to consumer module
  • Both L1 (memory) and L2 (disk) invalidation
  • Test cache invalidation triggers and produces fresh data on next fetch

11. unawaited() + Error Silent-Failure Pitfall (Critical)#

Background: #7746 โ€” an "infinite loading" bug found in SwrStrategyImpl._revalidate (background revalidation). The root cause is not SWR-specific โ€” it's a Dart language trait combined with unawaited() fire-and-forget โ€” so it reproduces identically in non-SWR code. See [cc-dev-cycle:discovery-audit] for the codebase-wide audit โ†’ confirm โ†’ parallel-fix workflow used after discovery.

11-1. Why โ€” Dart's Error does not extend Exception#

TypeError, StateError, NoSuchMethodError, FlutterError, JsonUnsupportedObjectError, etc. are all Error, not Exception. In practice, almost every catch block conventionally uses on Exception catch only.

This alone isn't a problem for a normal await-chained call โ€” an uncaught Error propagates up and eventually surfaces as a crash or a log. The problem is when this catch chain sits behind unawaited(someFunction()) (fire-and-forget). There, an Error missed by on Exception becomes an exception no code path is set up to observe. Whether it also surfaces as a crash/log elsewhere depends on the execution zone (a plain Dart isolate prints "Unhandled exception" and aborts; a Flutter app's guarded zone may route it to PlatformDispatcher.onError instead, or swallow it depending on what that handler does) โ€” but regardless of that, the specific consumer awaiting this result never gets a terminal emission: BLoC/Cubit state, a ValueNotifier/useState-gated button or spinner, a StreamController all hang forever waiting for a response that never arrives. Infinite loading spinners, permanently-disabled buttons, and dialogs that never close are the typical symptoms.

11-2. Reconstructed example (SwrStrategyImpl._revalidate pattern)#

// package/cache/lib/src/data/swr_strategy_impl.dart (reconstructed โ€” illustrates the pattern, not the literal diff)

void onListen() {
  // 1. emit cached data immediately
  // 2. background revalidate is fire-and-forget โ€” this is the trap
  unawaited(_revalidate(query, controller));   // โŒ any Error thrown inside disappears completely
}

Future<void> _revalidate(
  CacheQuery query,
  StreamController<Either<Failure, T>> controller,
) async {
  try {
    final fresh = await _fetcher(query);
    await saveToCache(query.cacheKey, fresh);   // may throw an Error internally (e.g. jsonEncode)
    if (!controller.isClosed) controller.add(right(fresh));
  } on Exception catch (error, stackTrace) {     // โŒ Error (TypeError/JsonUnsupportedObjectError/...) is NOT caught here
    Log.e('background revalidate failed', error: error, stackTrace: stackTrace);
    if (!controller.isClosed) controller.add(left(NetworkFailure(error.toString())));
  }
}

If something inside saveToCache or the response mapping throws an Error (e.g. JsonUnsupportedObjectError from a non-serializable object, or TypeError from an unsafe as T cast), it passes straight through on Exception catch and exits _revalidate. Neither controller.add branch runs, and since the call is unawaited(), nobody awaits the Future to notice the failure. Result: any BLoC subscribed to that cacheKey never receives a terminal emission and the screen stays stuck in loading state.

11-3. Detection โ€” grep-able once known#

# candidates โ€” every production file with a fire-and-forget unawaited( call
grep -rl  " unawaited( "   --include= " *.dart "   feature/ package/ app/ | grep -v _test.dart

# then manually trace the full call chain of each unawaited(fn()) to check
# whether EVERY catch clause in that chain is `on Exception` only (misses Error)

Full automation is hard because the offending catch can live in a function several calls below the one passed to unawaited() โ€” the full call chain must be traced. This is why a separate triage (cast a wide net) โ†’ verify (trace the chain, confirm) split matters; see [cc-dev-cycle:discovery-audit].

11-4. Real case#

An audit of all 274 production files containing unawaited( in kobic confirmed 11 independent reproducible instances (including a high-severity full EPUB-reader hang). Two "high" severity candidates were rejected as dead/@Deprecated code, and one was rejected because its consumer was a BehaviorSubject.seeded() โ€” it doesn't hang, it just serves stale data until the next update.

11-5. Fix#

// โœ… on Object catch covers both Exception and Error
} on Object catch (error, stackTrace) {
  Log.e('background revalidate failed', error: error, stackTrace: stackTrace);
  if (!controller.isClosed) controller.add(left(NetworkFailure(error.toString())));
}

Any function reached via unawaited() should widen its catch to on Object catch (or on Exception catch + a parallel on Error catch) โ€” this is the one place where the usual on Exception-only convention is not enough.

11-6. Checklist#

  • Before adding a new unawaited(fn()) call, verify the full call chain of fn() also catches Error
  • The top-level catch of any function reached via unawaited() is on Object (or on Exception + on Error)
  • The consumer (BLoC/Cubit/ValueNotifier/StreamController) is guaranteed a terminal emission (success or failure) even on this path
  • Same item is mirrored in the code review checklist: [cc-code-quality:code-review-checklist] ยง2 State Management

  • dcm-bloc.md - BLoC pattern rules
  • CLAUDE.md - Complete project guide
  • auth_required_usecase.dart - Existing UseCase Pattern
  • #7746 - SwrStrategyImpl._revalidate infinite-loading fix caused by unawaited() + missed Error
  • [cc-dev-cycle:discovery-audit] - Workflow used to audit/confirm/parallel-fix this bug pattern across the codebase