Skip to content

Proposal: keyed in-flight request coalescing (SingleFlight<K,V>) in package:async #962

Description

@bthnkucuk

Motivation

A common need in Dart apps — especially Flutter clients — is request
coalescing
: when N concurrent calls arrive for the same key while one is
already in flight, run the underlying work once and share the result with
every caller. Real cases we hit in production:

  • Token refresh: a burst of 401s from parallel API calls must trigger a
    single refresh, not N.
  • TTS / image / file download: bursty UI events (rapid scroll, repeated
    taps) fan out to the same endpoint within the same event-loop tick.
  • Cache stampede: a cache miss observed by N concurrent readers should
    result in one origin fetch, not N.

This is the classic "single-flight" pattern, popularized by Go's
golang.org/x/sync/singleflight
(originally from groupcache). It's a small primitive but ubiquitous in
backend and client code.

Gap in current Dart options

  • package:async ships AsyncCache and AsyncMemoizer, both of which
    dedupe in-flight calls — but for a single, fixed key:

    • AsyncMemoizer: one-shot, never resets.
    • AsyncCache: one key per instance, caches the result for a TTL.

    Neither covers the keyed + no-caching + multi-round use case (e.g. one
    SingleFlight<String, User> shared across fetchUser('42'),
    fetchUser('57'), …).

  • Pub.dev's singleflight exists
    but appears unmaintained:

    • Last update 19 months ago, 0 likes, ~2.4k total downloads.
    • Source repo (github.com/bachue/dart-singleflight) returns 404.
    • Unverified uploader.
    • API: String-only keys, no Forget, depends on the mutex package
      (which is unnecessary on Dart's single-threaded event loop).

So there's a real gap and no maintained third-party fills it. package:async
is the natural home — it already groups async coordination primitives
(AsyncCache, AsyncMemoizer, CancelableOperation, StreamGroup, etc.).

Proposed API

class SingleFlight<K, V> {
  /// Runs [compute] for [key], deduplicating concurrent calls.
  /// Returns the value plus a `shared` flag — `true` if at least one other
  /// caller waited on the same in-flight execution.
  /// Mirrors Go's `Do(key, fn) (val, err, shared)`.
  Future<SingleFlightResult<V>> run(K key, Future<V> Function() compute);

  /// Drops [key] from the in-flight map without canceling the running call.
  /// Already-waiting callers still receive that flight's result; subsequent
  /// callers start a fresh flight. Mirrors Go's `Forget(key)`.
  void forget(K key);

  /// Keys currently in flight, for telemetry/debugging.
  Iterable<K> get inFlightKeys;
}

class SingleFlightResult<V> {
  final V value;
  final bool shared;
}

## Reference implementation

A working version is in use in our app monorepo. ~80 LOC, no extra deps,
passes 77 tests including error propagation, mid-flight `forget`,
record-key dedup, and 50-way concurrent fan-out.

/// In-flight request coalescing — Dart port of Go's
/// `golang.org/x/sync/singleflight`.
class SingleFlight<K, V> {
  final _ongoing = <K, _Call<V>>{};

  Future<SingleFlightResult<V>> run(
    K key,
    Future<V> Function() compute,
  ) async {
    final existing = _ongoing[key];
    if (existing != null) {
      existing.dups++;
      final value = await existing.future;
      return SingleFlightResult._(value, shared: true);
    }

    final call = _Call<V>(compute());
    _ongoing[key] = call;
    try {
      final value = await call.future;
      return SingleFlightResult._(value, shared: call.dups > 0);
    } finally {
      // Only release the slot if it still points to *this* call —
      // forget() may have replaced it with a fresh flight already.
      if (identical(_ongoing[key], call)) _ongoing.remove(key);
    }
  }

  void forget(K key) => _ongoing.remove(key);

  Iterable<K> get inFlightKeys => _ongoing.keys;
}

class SingleFlightResult<V> {
  const SingleFlightResult._(this.value, {required this.shared});
  final V value;
  final bool shared;
}

class _Call<V> {
  _Call(this.future);
  final Future<V> future;
  int dups = 0;
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions