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;
}
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:
single refresh, not N.
taps) fan out to the same endpoint within the same event-loop tick.
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:asyncshipsAsyncCacheandAsyncMemoizer, both of whichdedupe 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 acrossfetchUser('42'),fetchUser('57'), …).Pub.dev's
singleflightexistsbut appears unmaintained:
github.com/bachue/dart-singleflight) returns 404.Forget, depends on themutexpackage(which is unnecessary on Dart's single-threaded event loop).
So there's a real gap and no maintained third-party fills it.
package:asyncis the natural home — it already groups async coordination primitives
(
AsyncCache,AsyncMemoizer,CancelableOperation,StreamGroup, etc.).Proposed API