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 - Complete loss of SWR benefits#

// โŒ WRONG: .last only waits for Stream's last value (server data)
// Ignores immediately emitted cache data -> shows loading spinner until server response
final result = await _useCases.getBooks(params).last;

Problem: The SWR "show stale data first" pattern is completely invalidated. Actual case: 7 methods in my_library_bloc lost cache benefits due to .last usage (fixed in PR #4584).

โŒ .first - Ignoring server refresh#

// โŒ WRONG: .first only collects Stream's first value (cache)
// Ignores latest data from server -> only shows stale cache data
final result = await _useCases.getUserInfo(const NoParams()).first;

Problem: The "background refresh" part of SWR is lost. Actual case: 10 calls in my_page_bloc using .first (fixed in PR #4585).

Exception: In Future.wait parallel loading, the pattern of quickly showing cache with .first while adding emit.forEach background refresh via separate events is allowed.

โŒ 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.

โœ… 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. Checklist#

SWR Implementation Checklist#

  • Use async* + yield pattern in Repository
  • UseCase returns Stream<Either<Failure, T>>
  • Use emit.forEach in BLoC (.last/.first prohibited)
  • Apply restartable() transformer to events using emit.forEach
  • Skip loading state when cache data exists
  • Specify onError callback
  • Allow .last exception for pagination Load More
  • Add lastUpdated or data source distinction field to State