Posterus
Composable async primitives with cancelation, control over scheduling, and coroutines. Superior replacement for JS Promises.
Install / Use
/learn @mitranim/PosterusREADME
Overview
Superior replacement for JS promises.
Similarities with promises:
- Supports mapping, flatmapping, grouping, etc.
- Supports coroutines (via generators, see
./fiber.mjs).
Main differences from promises:
- Supports cancelation.
- Mostly synchronous.
- Exposes its scheduler, allowing to opt into asynchrony, and opt out by flushing pending tasks on demand.
- Supports "errbacks": single callback that receives "err, val".
- Constructor doesn't require a callback.
- Mapping mutates the task instead of allocating new instances.
- Doesn't store results.
- Dramatically simpler and faster.
Small (<12 KiB unminified) and dependency-free. Usable as a native JS module.
Optionally supports coroutines/fibers (<2 KiB unminified). Replacement for async/await, with implicit ownership and cancelation of in-progress work. See API (fiber.mjs).
Convertible to and from promises.
TOC
- Why
- Usage
- API
- API (
fiber.mjs) - Changelog
Why
- Correct async programming requires cancelation.
Promiseis crippled by lack of cancelation.Promiseis further mangled by mandatory asynchrony.- Replacing the broken model is better than trying to augment it.
Why cancelation?
Humans and even programs change their minds all the time. Many behaviors we consider intuitive rely on some form of cancelation.
-
Start playing a video, then hit stop. Should it finish playing?
-
Click a web link, then immediately click another. Should it still load the first link?
-
Start uploading a file, then hit stop. Should it upload anyway?
-
Run an infinite loop. Should it hog a CPU core until you reboot the operating system?
-
Hit a button to launch nuclear missiles, immediately hit abort. Nuke another country anyway?
What does it mean for the programmer?
First, this mostly applies to user-driven programs. The concept of cancelation to a normal synchronous program is like the 4th spatial dimension to a human mind: equally incomprehensible.
Synchronous code tends to be a sequence of blocking operations, with no room for changing one's mind. This makes it inherently unresponsive and therefore unfit for user-driven programs. Said programs end up using multithreading, event loops, and other inherently asynchronous techniques. The asynchrony is how you end up with abstractions like promises, responding to a fickle user is how you end up needing cancelation, and being responsive is how you're able to cancel.
Sync and async programming are inherently complementary. For invididual operations, we tend to think in sequential terms. For systems, we tend to think in terms of events and reactions. Neither paradigm fully captures the needs of real-world programming. Most non-trivial systems end up with an asynchronous core, laced with the macaroni of small sequential programs that perform individual functions.
JavaScript forces all programs to be asynchonous and responsive. Many of these programs don't need the asynchrony, don't respond to fickle agents, and could have been written in Python. Other programs need all of that.
Here's more examples made easier by cancelation.
1. Race against timeout
With promises (broken):
Promise.race([
after(100).then(() => {
console.log('running delayed operation')
}),
after(50).then(() => {throw Error('timeout')}),
])
function after(time) {
return new Promise(resolve => setTimeout(resolve, time))
}
Timeout wins → delayed operation runs anyway. Is that what we wanted?
Now, with cancelable tasks:
import * as p from 'posterus'
p.race([
after(100).mapVal(() => {
console.log('running delayed operation')
}),
after(50).mapVal(() => {throw Error('timeout')}),
])
function after(time) {
const task = new p.Task()
const cancel = timeout(time, task.done.bind(task))
task.onDeinit(cancel)
return task
}
function timeout(time, fun, ...args) {
return clearTimeout.bind(undefined, setTimeout(fun, time, ...args))
}
Timeout wins → delayed operation doesn't run, and its timer is actually canceled.
2. Race condition: updating page after network request
Suppose we update search results on a webpage. The user types, we make requests and render the results. The input might be debounced; it doesn't matter.
With regular callbacks or promises:
function onInput() {
httpRequest(searchParams).then(updateSearchResults)
}
Eventually, this happens:
request 1 start ----------------------------------- end
request 2 start ----------------- end
After briefly rendering results from request 2, the page reverts to the results from request 1 that arrived out of order. Is that what we wanted?
Instead, we could wrap HTTP requests in tasks, which support cancelation:
function onInput() {
if (task) task.deinit()
task = httpRequest(searchParams).mapVal(updateSearchResults)
}
Now there's no race.
This could have used XMLHttpRequest objects directly, but it shows why cancelation is a prerequisite for correct async programming.
3. Workarounds in the wild
How many libraries and applications have workarounds like this?
let canceled = false
asyncOperation().then(() => {
if (!canceled) {/* do work */}
})
const cancel = () => {canceled = true}
Live example from the Next.js source: https://github.com/zeit/next.js/blob/708193d2273afc7377df35c61f4eda022b040c05/lib/router/router.js#L298
Workarounds tend to indicate poor API design.
Why not extend standard promises?
1. You're already deviating from the spec
Cancelation support diverges from the spec by requiring additional methods. Not sure you should maintain the appearance of being spec-compliant when you're not. Using a different interface reduces the chances of confusion, while conversion to and from promises makes interop easy.
2. Unicast is a better default than broadcast
Promises are broadcast: they have multiple consumers. Posterus defaults to unicast: tasks have one consumer/owner, with broadcast as an option.
Broadcast promises can support cancelation by using refcounting, like Bluebird. It works, but at the cost of compromises (pun intended) and edge cases. Defaulting to a unicast design lets you avoid them and greatly simplify the implementation.
See Unicast vs Broadcast for a detailed explanation.
Posterus provides broadcast as an opt-in.
3. Annoyances in the standard
Errbacks
This is a minor quibble, but I'm not satisfied with then/catch. It forces premature branching by splitting your code into multiple callbacks. Node-style "errback" continuation is often a better option. Adding this is yet another deviation. See task.map.
External Control
How many times have you seen code like this?
let resolve
let reject
const promise = new Promise((a, b) => {
resolve = a
reject = b
})
return {promise, resolve, reject}
Occasionally there's a need for a promise that is controlled "externally". The spec goes out of its way to make it difficult.
In Posterus:
import * as p from 'posterus'
const task = new p.Task()
That's it! Call task.done() to settle it.
Error Flattening
Promise.reject(Promise.resolve(...)) passes the inner promise as the eventual result instead of flattening it. I find this counterintuitive.
import * as p from 'posterus'
Promise.reject(Promise.resolve('<result>')).catch(val => {
console.log(val)
})
// Promise { '<result>' }
p.async.fromErr(p.async.fromVal('<result>')).mapErr(val => {
console.log(val)
})
// <result>
Unicast vs Broadcast
The GTOR defines a "task" as a unit of delayed work that has only one consumer. GTOR calls this unicast as opposed to promises which have multiple consumers and are therefore broadcast.
Why are promises broadcast, and Posterus unicast? My reasons are somewhat vague.
Async primitives should be modeled after synchronous analogs:
-
Sync → async: it guides the design; the user knows what to expect.
-
Async → sync: we can use constructs such as coroutines that convert async primitives back to the sync operations that inspired them.
Let's see how promises map to synchronous constructs:
const first = '<some value>'
const second = first // share once
const third =
Related Skills
node-connect
334.5kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
82.2kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
334.5kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
commit-push-pr
82.2kCommit, push, and open a PR
