Open it once, on first use. Write it once, for everyone who asked.
Two primitives, memo and batch, and one mechanism underneath them, shared with the runtime's own construction of services. This page explains the mechanism once, then each primitive on a realistic case: a messaging client whose local store is backed by IndexedDB.
A cell runs an effect at most once and shares the outcome
Both primitives, with timings you can watch, are in the playground.
memo makes anonymous ones. batch uses the same fiber discipline per run.Three rules follow, and they are the same for a service, a memoised resource and a batch:
- The run happens on a fiber with no parent, against the service map captured when the cell was made. A demander interrupted while waiting simply stops waiting; it cannot cancel a shared run. An
acquireinside registers on the scope the cell was created in, not on whichever request came first. - Concurrent demands join the one run. The value is written once.
- Failure is not cached. Joiners receive it, the cell goes back to empty, and the next demand tries again. Backoff is composition:
memo(retry(policy)(open)).
memo(fx): the read is the sync point, not the constructor
The case it exists for: a store backed by IndexedDB. Opening is asynchronous, must happen once, nothing should wait for it at startup, and the first read should. Consumers must not know.
Yielding memo(fx) does not run fx. It records fx's requirements against the body it is yielded in, so they land in the provider's Needs, captures the current service map and scope, and returns a dependency-free handle. Yielding the handle runs fx on first demand. That is why the contract below says Fx<Message | undefined, IdbError> with nothing in the third slot, and why the provider cannot fail at construction: the open's failure belongs to the read that triggered it.
export const LocalStoreLive = LocalStore.make(function* () {
const config = yield* Config
// Opened once, on the first read, closed with the runtime.
const db = yield* memo(
acquire(
() => openMessages('messages-' + config.userId),
(store) => store.close(),
(reason) => new IdbError(reason)
)
)
return {
get: (id) => gen(function* () {
const store = yield* db // the read is the sync point
return yield* fromPromise(() => store.get(id), (reason) => new IdbError(reason))
}),
}
})
// Provider<'LocalStore', 'Config', never>
memo(fx, { eager: true }) starts the run at the point of creation, in the background: reads arriving before it settles wait, reads after do not.Outside a provider it captures the request: const user = yield* memo(db.query(id)) is one query however often the handle is yielded, and it is gone with the request. A per-key cache is a Map of handles, not a new primitive.
"I will need Mps, later"
A service key is itself an Fx, so memo applies to it with no API of its own. Yielding memo(Mps) at construction records 'Mps' in the provider's Needs, so the list is still checked as a whole, and returns a handle. Yielding the handle later resolves Mps then: builds it if the cell is empty, joins if it is in flight, returns it if it is done. The contract is untouched.
Take a messaging client's Send: it seals a message, transmits it, then hands it to Mps, the message-processing service, to persist. Mps is needed only for that last step, and at the moment a send happens it may be unbuilt, or halfway through construction because Receive is building it.
export const SendLive = Send.make(function* () {
const config = yield* Config
const crypto = yield* Crypto
const network = yield* Network
const lazyMps = yield* memo(Mps) // recorded in Needs now; resolved later
return {
send: (threadId, payload) => gen(function* () {
const envelope = yield* crypto.seal(inbound)
yield* network.send(envelope)
const mps = yield* lazyMps // empty → builds · inflight → joins · done → returns
return yield* mps.ingest(inbound)
}),
}
})
// Provider<'Send', 'Config' | 'Crypto' | 'Network' | 'Mps', never>
batch(run): many single calls, one run
batch turns an operation over many items into one over single items whose calls are coalesced. A batch collects until window milliseconds after its first item (default 0, the next turn of the event loop) or until it holds maxSize items. The run happens on a parentless fiber against the captured map, and a finalizer flushes whatever is pending when the owning scope closes, so nothing enqueued is lost at shutdown.
| Member | Does | Waits? |
|---|---|---|
add(item) | adds an item; the caller receives its own element of the result, by position | yes, for its batch. DataLoader semantics. Needs an async entry point. |
enqueue(item) | adds an item and returns | no. Write-behind. Works under runSync. |
flush | runs what is pending now and waits for every batch in flight | yes |
pending | items added but not yet handed to a run | no |
The store that motivated it uses enqueue: put makes the row visible at once and hands it to the batch, so a hundred puts in a synchronous run are one transaction.
let writes = 0
const writer = yield* batch(
(messages: readonly Message[]) => sync(() => { writes += 1; return messages }), // one transaction
{ maxSize: 500 }
)
put: (message) => gen(function* () {
const rows = yield* db
rows.set(key(message), message) // visible now
yield* writer.enqueue(message) // durable with its neighbours, shortly
return message
}),
flush: writer.flush,
writes: sync(() => writes),
Failure of a run reaches every add waiting on it and the next flush; the following batch is independent. A run that produces a different number of results than it was given items is a defect.
Where coalescing lives is decided by who reads
In the messaging client the write buffer lives in LocalStore, not in Mps, although Mps orchestrates persistence. The reason is the read path. Validation stages and handlers read the store directly, and a reaction that arrives in the same batch as its target has to find that target. A buffer held by MPS would hide the target until flushed, and the reaction would be rejected. Pending rows must be readable before they are durable, and only the store can arrange that.
The corollary is that a batch is processed in order, one envelope at a time. Order is what makes an in-batch reference safe, and the write coalescing does not need concurrency to pay off.