Op — Managing Async Operations
5.96 kB minified + gzipped
Managing asynchronous workflows in frontend components or backend services usually couples task logic directly to execution controls. Consider a standard search-as-you-type input written in vanilla TypeScript:
This pattern exposes four critical failure modes in production:
- The
finallystate race: When a user types a new character, the previous request is aborted. Itsfinallyblock runs immediately and resetsisLoading = false, hiding the loading indicator while the second request is still waiting for network bytes. - Out-of-order response overwrites: If a developer forgets the
AbortControllerentirely, a slower response from an earlier short query ("ca") can resolve after a faster response from a longer query ("cat"), overwriting fresh results with stale data. - Contradictory state flags: Juggling separate
isLoading,searchError, andsearchResultsvariables allows impossible states to render (such as an error banner appearing alongside stale data while a spinner is active). - Coupled execution logic: Switching from “cancel previous” (
restartable) to “ignore clicks while submitting” (exclusivefor checkout buttons) requires rewriting the entire control flow from scratch with guard flags and manual timers.
Op<Args, E, A> decouples the what (the core asynchronous task) from the how (the execution strategy). You describe the asynchronous work once with Op.make, then configure concurrency, retries, and timeouts declaratively with Op.interpret.
Type Structure & Mental Model
Section titled “Type Structure & Mental Model”Op separates asynchronous coordination into two distinct layers: the task definition and the stateful manager.
flowchart TB
A["1. Op.make"] -->|"Define core async work"| B["2. Op.interpret"]
B -->|"Configure concurrency strategy"| C["3. manager.subscribe"]
C -->|"Observe lifecycle transitions"| D["4. manager.run"]
D -->|"Every invocation settles"| E{"Op.Outcome(E, A)"}
E -->|"Success"| Ok["OpOk (value: A)"]
E -->|"Typed Failure"| Err["OpErr (error: E)"]
E -->|"Cancelled / Dropped / Replaced"| Nil["OpNil (reason: NilReason)"]
The Task Definition (Op<Args, E, A>)
Section titled “The Task Definition (Op<Args, E, A>)”An Op<Args, E, A> is an immutable blueprint for fallible asynchronous work. It accepts an AbortSignal and your domain arguments, returning a promise. It never runs on its own:
The Execution Manager (Op.Manager<Args, E, A>)
Section titled “The Execution Manager (Op.Manager<Args, E, A>)”Created by passing an Op blueprint to Op.interpret(op, options). The manager owns the active state machine, enforces your chosen concurrency model (restartable, exclusive, queue, buffered, debounced, throttled, concurrent, keyed, once), and coordinates subscriptions.
The Lifecycle State (Op.State<E, A>)
Section titled “The Lifecycle State (Op.State<E, A>)”The manager transitions through observable lifecycle states:
stateDiagram-v2 direction TB [*] --> Idle Idle --> Pending : run() Pending --> Queued : capacity reached Queued --> Pending : slot available Pending --> Retrying : retry scheduled Retrying --> Pending : retry triggered Pending --> OpOk : success Pending --> OpErr : failed Pending --> OpNil : cancelled / replaced OpOk --> Idle : reset / next run OpErr --> Idle : reset / next run OpNil --> Idle : reset / next run
The Tri-State Outcome (Op.Outcome<E, A>)
Section titled “The Tri-State Outcome (Op.Outcome<E, A>)”Every invocation triggered via manager.run(...args) returns a promise (Deferred<Op.Outcome<E, A>>) that is guaranteed to settle. Invocations never hang or reject unexpectedly. Every run settles into one of three variants:
OpOk: The operation completed successfully with valueA.OpErr: The operation failed with a typed domain errorE.OpNil: The operation did not finish because the concurrency strategy cancelled, dropped, replaced, or evicted it.
Creating Operations
Section titled “Creating Operations”Op.make defines an operation by accepting an asynchronous runner function and an error options object:
The outer factory receives an AbortSignal. The inner returned function defines the actual action and its arguments. Pass the signal to cancellable operations (such as fetch). TypeScript automatically infers the parameters of the inner function, guaranteeing that manager.run(...args) is fully typed without repeating type annotations.
If the async function is defined elsewhere with signal curried first, you can pass its reference directly:
Lifting Plain Async Functions with Op.lift
Section titled “Lifting Plain Async Functions with Op.lift”For rapid prototyping or utilities where typed domain error mapping is unnecessary, Op.lift wraps a plain async function. Any uncaught rejection is captured automatically as Err<unknown>:
The Execution Lifecycle & Type Guards
Section titled “The Execution Lifecycle & Type Guards”To execute an operation, interpret it with a concurrency strategy and trigger run():
Both Op.make and Op.interpret accept an optional id for diagnostics. In DevTools and loggers,
the manager displays as [object Op.Manager(profile-loader)[fetchUser]], combining the instance
identifier with the blueprint definition without conflating runtime identity.
Inspecting Lifecycle States with Op.is
Section titled “Inspecting Lifecycle States with Op.is”The manager state transitions through active states before settling into an Op.Outcome. Use the Op.is type guards to inspect the manager’s lifecycle stage:
Op.is.idle(state)— true before the first run or aftermanager.reset().Op.is.pending(state)— true while an operation is actively executing.Op.is.queued(state)— true when waiting in a queue (state.positionindicates the 1-based queue slot).Op.is.retrying(state)— true when waiting between retry attempts (state.attempt,state.lastError,state.nextRetryIn).Op.is.settled(state)— true when the state has settled into anOp.Outcome(Ok,Err, orNil).
Choosing a Concurrency Strategy
Section titled “Choosing a Concurrency Strategy”The execution strategy dictates what happens when run() is triggered while an existing operation is already in flight.
Single-Execution & Exclusive Strategies
Section titled “Single-Execution & Exclusive Strategies”restartable: New calls immediately abort any active in-flight request (reason: "replaced") and start a fresh run. Only the latest invocation ever completes. Userestartablefor autocomplete inputs, search boxes, and tab switching.exclusive: New calls are ignored (reason: "dropped") while an operation is actively in flight. The active request always runs to completion. Useexclusivefor form submissions and payment checkouts to prevent double-submits.once: Fires at most once. Only the firstrun()executes, and all subsequent calls resolve immediately toNil(reason: "dropped"). Once the first invocation finishes, its settled state is cached permanently (untilmanager.reset()is called). Useoncefor app bootstrap and initial configuration loads.
Queuing & Draft Buffering Strategies
Section titled “Queuing & Draft Buffering Strategies”queue: Calls are placed in a FIFO queue (Queuedstate) and executed sequentially in the order they were submitted. Supports optionalmaxSize,overflow("drop"or"replace-last"),concurrency, anddedupe. Ideal for transactional batch steps and offline sync queues.buffered: Maintains 1 active execution slot and a waiting buffer (default size 1). When a new call arrives while one is already waiting, the older waiting call is evicted (reason: "evicted") and replaced by the latest call. Usebufferedfor auto-saving editor drafts.
Rate-Limiting & Parallel Strategies
Section titled “Rate-Limiting & Parallel Strategies”debounced: Waits for a quiet period ofdurationbefore starting execution. Every new call resets the timer and evicts the previous pending call (reason: "evicted"). Supports optionalleading: trueandmaxWait:
throttled: Fires immediately on the first call, then locks out subsequent calls forduration. By addingtrailing: true, the most recent call made during the cooldown window fires once when the cooldown expires:
concurrent: Runs up toconcurrencyoperations simultaneously. When capacity is full,overflow: "queue"places callers into a waiting queue (Queuedstate), whereasoverflow: "drop"immediately resolves excess calls toNil(reason: "dropped"):
keyed: Multiplexes independent state machines per input key, running different keys in parallel while applying aperKeysub-strategy ("exclusive"or"restartable") to calls sharing the same key.manager.stateis aReadonlyMap<K, State>:
State Subscriptions, Cancellation, and UI Integration
Section titled “State Subscriptions, Cancellation, and UI Integration”The manager returned by Op.interpret owns the active state machine. You can subscribe to state changes, read manager.state synchronously, or cancel in-flight work:
The manager.state property is always available for synchronous reads, making it straightforward to bind to reactive UI frameworks:
manager.abort(): Cancels any active or queued executions immediately, resolving their callers withNil(reason: "aborted"). On akeyedmanager,manager.abort(key)aborts a specific key, ormanager.abort()aborts all keys.manager.reset(): Returns the manager state toIdle(or clears the map on akeyedmanager).manager.poll({ interval })(...args): Repeatedly triggersrun(...args)on a fixed interval, returning a cleanup function to stop polling.
Per-Invocation Awaiting & Settlement Invariant
Section titled “Per-Invocation Awaiting & Settlement Invariant”Every time you invoke manager.run(...args), it returns a Deferred value that resolves to the specific settled outcome (Op.Outcome) of that invocation. Even if an invocation is replaced or aborted by a subsequent call, its promise is guaranteed to settle with Nil rather than hanging:
Because manager.run() returns a Deferred, combining multiple in-flight invocations uses Deferred.all or Deferred.race:
Declarative Resilience: Retries & Timeouts
Section titled “Declarative Resilience: Retries & Timeouts”Retries and timeouts are configured declaratively at interpretation time, keeping operational concerns out of your core async definitions:
If a retry policy is active, the manager’s state union automatically includes Op.Retrying<E> between attempts, allowing the UI to inspect state.attempt, state.lastError, and state.nextRetryIn.
Working with Op.Outcome
Section titled “Working with Op.Outcome”Op.Outcome is a complete container namespace with constructors (make.ok, make.err, make.nil), type guards (is.ok, is.err, is.nil), transformations (map, mapError, chain, recover), side-effect taps (tap, tapError), and eliminators (match, getOrElse).
The Nil variant carries a precise reason explaining why the invocation did not finish:
"aborted"—manager.abort()was called explicitly."dropped"— the invocation was ignored because the strategy had no remaining capacity."replaced"— a newerrun()call executed, cancelling this in-flight invocation."evicted"— the invocation was removed from a waiting queue or buffer before starting.
When sequencing another fallible step that returns an Op.Outcome or handling all three branches explicitly, use Op.Outcome.chain and Op.Outcome.match:
Outcome Conversions and Wiring
Section titled “Outcome Conversions and Wiring”At system boundaries, convert between Op.Outcome and Result or Maybe:
Op.Outcome.to.Result(onNil): MapsOktoOk,ErrtoErr, andNiltoErr(onNil(reason)).Op.Outcome.to.Maybe(outcome): MapsOktoSome, and maps bothErrandNiltoNone.Op.Outcome.from.Result(result): MapsResult.OktoOp.Outcome.OkandResult.ErrtoOp.Outcome.Err.Op.Outcome.from.Maybe(maybe, onNone?): MapsMaybe.SometoOp.Outcome.OkandMaybe.NonetoOp.Outcome.Nil(defaulting reason to"dropped").
To trigger a downstream manager or side effect automatically whenever a source manager settles with Ok, use Op.wire:
Composing Outcome Pipelines
Section titled “Composing Outcome Pipelines”Here is an end-to-end example processing an order checkout. It awaits an exclusive payment submission, verifies discount codes, recovers from non-fatal inventory alerts, logs auditing records, and extracts a customer confirmation banner:
Problems It Solves
Section titled “Problems It Solves”- Search-as-you-type and tab navigation without network race conditions (
restartable): In autocomplete inputs or rapid tab navigation, fast user actions fire multiple overlapping HTTP requests. Slower earlier responses often arrive after newer ones, overwriting fresh data with stale results. Settingstrategy: "restartable"automatically aborts previous in-flight HTTP requests viaAbortSignal, freeing up browser socket connections and guaranteeing only the latest network response updates state. - Preventing duplicate mutation requests and double charges (
exclusive): Rapidly clicking payment, checkout, or delete buttons can send duplicate HTTP POST/PUT requests to backend servers. Settingstrategy: "exclusive"ignores subsequent triggers while a network mutation is actively in flight, structurally eliminating duplicate API submissions without manual component boolean flags. - Auto-saving editor drafts without API congestion (
bufferedanddebounced): In rich text editors and settings forms, sending an HTTP PUT/PATCH request on every single keystroke floods the backend server, while saving only on unmount risks data loss.bufferedkeeps at most one active network request in flight and one waiting draft (automatically replacing superseded payloads), whiledebouncedwaits for typing pauses before dispatching the HTTP save. - Per-entity independent network requests in tables and feeds (
keyed): In data tables or dashboard lists where users trigger actions on individual items (such as archiving a row, fetching order details, or retrying a failed webhook), tracking loading and error states per row manually requires complex ID-mapped state dictionaries.keyedmultiplexes independent request managers per entity ID, isolating in-flightAbortSignals and state transitions so one item’s slow network request never blocks or cancels another. - Rate-limited batch uploads and connection pool management (
concurrent): Firing dozens of simultaneous HTTP requests (such as uploading file batches or fetching paginated collections) can saturate browser connection limits (such as HTTP/1.1 six-connection host limits) or trigger HTTP 429 Too Many Requests errors.concurrentwithoverflow: "queue"caps simultaneous active requests (e.g.concurrency: 3) and queues the remainder, whileoverflow: "drop"discards excess requests when rate limits are saturated. - Sequential transactional API queues (
queue): In multi-step backend operations (such as creating an account, provisioning tenant databases, and sending invitations) or offline sync queues, HTTP requests must execute in strict chronological order where each request depends on the completion of the previous one.queueserializes all network invocations in strict FIFO order without manual promise queue plumbing. - Rate-limited telemetry and high-frequency API sync (
throttled): High-frequency actions (such as sending real-time cursor presence beacons, analytics pings, or rapid refresh requests) risk overwhelming API endpoints.throttledguarantees network requests fire at most once per time window, executing immediately on the leading edge and optionally dispatching the latest payload on the trailing edge. - One-time session bootstrap and configuration fetching (
once): In web applications, fetching auth session tokens, remote feature flags, or global tenant settings during app startup must hit the network exactly once.onceguarantees that only the first request reaches the network, permanently caching the result and resolving subsequent calls immediately without repeated API fetches. - Network resilience with declarative retries, timeouts, and polling (
retry,timeout,poll): Flaky mobile connections, temporary 503 Service Unavailable spikes, and hanging socket connections require backoff retry strategies and hard timeout limits.Opencapsulates backoff schedules, error filter predicates (when), timeout aborts, and automated interval polling (manager.poll) into a declarative configuration blueprint, cleanly decoupled from business code. - Tying request lifecycle states to reactive UI stores without inconsistent flags: Managing network lifecycles with separate
isLoading,isError,error, anddataflags leads to contradictory UI states (such as spinners showing alongside error alerts).Opmanagers provide a single reactive state machine with synchronousmanager.statereads, making integration with React (useSyncExternalStore), Vue, or Svelte predictable and exhaustive.