Skip to main content

Deferred

Deferred<A> is a purely functional, write-once synchronization primitive. It starts empty and can be completed with a value exactly once. Any fiber that calls value() before the value is available will suspend until another fiber calls complete, at which point all waiting fibers are unblocked simultaneously.

info

Deferred is to IO what Completer is to Future — a way to hand a result from one fiber to another. Unlike Completer, Deferred is referentially transparent: allocation and completion are both IO effects.

How it differs from Queue

Queue and Deferred both let fibers communicate, but they serve different needs:

Queue<A>Deferred<A>
CapacityHolds many valuesHolds exactly one value, forever
ConsumersEach value is taken by one consumerAll waiting consumers unblock at once
ReuseCan send many messagesWrite-once — completed value is permanent
Use forStreams of work itemsOne-shot signals and startup coordination

Core operations

MethodReturnsDescription
Deferred.of<A>()IO<Deferred<A>>Allocate a new, empty Deferred
complete(a)IO<bool>Set the value; true if this was the first call, false if already set
value()IO<A>Get the value, suspending if not yet available
tryValue()IO<Option<A>>Get the value immediately as OptionNone if not yet completed

Basic usage

IO<Unit> deferredBasic() => Deferred.of<int>().flatMap(
(d) =>
IO
.both(
// Consumer: suspends on value() until the Deferred is completed
d.value().flatMap((n) => IO.print('got: $n')),
// Producer: completes the Deferred after a short delay
IO
.sleep(100.milliseconds)
.productR(() => d.complete(42))
.flatMap((won) => IO.print('completed: $won')), // completed: true
)
.voided(),
);

The consumer fiber suspends on value() immediately. When the producer calls complete(42) after the sleep, the consumer is woken up and proceeds.

Write-once guarantee

complete is idempotent after the first call — subsequent calls return false and have no effect. The value set by the first caller is preserved permanently:

IO<Unit> completeOnce() => Deferred.of<String>().flatMap(
(d) => IO
.both(
d.complete('first').flatMap((won) => IO.print('first: $won')), // first: true
d.complete('second').flatMap((won) => IO.print('second: $won')), // second: false
)
.flatMap((_) => d.value().flatMap((v) => IO.print('value: $v'))),
); // value: first

This makes Deferred safe to use in races: whichever fiber wins the complete call sets the canonical result, and all other attempts are silently ignored.


Real-world example: service startup gate

A common pattern in concurrent programs is to start several fibers that depend on a shared resource that takes time to initialize. Rather than polling or sleeping, a Deferred acts as a gate: dependents call value() and suspend; the initializer calls complete once, instantly releasing every waiting fiber.

The example below models a service that publishes its endpoint once it has finished starting up. Three client fibers wait on the same Deferred — they all unblock the moment the service is ready, without any polling:

/// A service publishes its endpoint via [Deferred] once startup is done.
/// Any number of client fibers can call [ready.value()] and will all unblock
/// at the same instant, the moment [complete] is called.
IO<Unit> serviceReadyGate() => Deferred.of<String>().flatMap((ready) {
IO<Unit> client(int id) =>
ready.value().flatMap((endpoint) => IO.print('client $id: connected to $endpoint'));

final service = IO
.sleep(200.milliseconds)
.productR(() => ready.complete('https://api.example.com'))
.productR(() => IO.print('service: startup complete, clients unblocked'));

return ilist([client(1), client(2), client(3), service]).parSequence_();
});

Note that it doesn't matter how many clients are waiting — the single complete call broadcasts the result to all of them in one step. This is the key advantage over Queue, which would require the service to offer once per client.