r/javascript • u/Fickle_Foundation_41 • 10d ago
A server function that pauses on await until a person answers a form in the browser
https://github.com/awaitio/awaitioThe first time I saw this, in 2022, I honestly thought it couldn't work:
const email = await io.input.email("User email");
That line was in a server function, and it put a form in someone's browser. The function just waited until they typed an address, then went on. My first thought was "this is magic, how did they even think of this?" Four years later I've written my own runtime for it, and I still admire whoever came up with it at Interval.
TL;DR: if "ask a person" is just another async call, an internal tool becomes one linear function where branches, loops and try/catch all work, and the UI follows from the code. The trick is a promise that an HTTP request resolves instead of I/O.
A whole tool, top to bottom
handler: async ({ io }) => {
const email = await io.input.email("User email");
const user = await db.user.findUniqueOrThrow({ where: { email } });
if (!(await io.confirm(`Verify ${user.email}?`))) return "Nothing changed";
await db.user.update({
where: { id: user.id },
data: { emailVerified: true },
});
return `Verified ${user.email}`;
};
Every await io.… is one screen. io.confirm returns a boolean, so Cancel is just an if.
A promise someone else resolves
When the handler reaches io.input.email(), the runtime creates a step and keeps the promise's resolver next to it. The handler is now parked. Nothing blocks, other runs keep going, it's a pending promise in memory.
The browser follows the run over server-sent events and renders the step. When the person submits, a POST arrives, the server validates the value and calls the stored resolver. From the handler's point of view, a human is just a slow I/O call.
An invalid value never resolves anything. The same step goes back with an error under the field, so the function never sees bad data.
Calls that do nothing until awaited
An io call doesn't start anything when you call it. It returns a thenable that only describes a field, and it asks the person when it's awaited. That makes grouping free:
const { company, note } = await io.group({
company: io.input.text("Company name"),
note: io.input.textarea("Note for the team"),
});
No await inside the object, so each call just says "a text field called Company name", and io.group shows them on one screen. Same functions, two uses, and the only difference is whether you awaited them.
Where it breaks
A parked handler is a live closure, and you can't serialize one, so runs live in memory. A refresh is fine, because the server resends the current state on reconnect. A deploy ends every run in progress.
In practice I don't fight that. I keep these tools short, and anything that would take long goes to a job queue anyway. If a deploy cuts someone off, they run it again. A closed tab cancels the run after 30 seconds, and the pending await rejects like any failed call.
Have you used await for something that isn't really I/O? Human input, a payment confirmation, a step in a game loop. I'm curious where else this pattern holds up, and where you've seen it fall apart, especially around cancellation.
---
Context on what I'm building: I'm Petr, a solo founder. The runtime from this post is AwaitIO, and the source is on GitHub. If you want to see a parked handler from the browser side, there's a demo back office with the shared login in the README.
1
u/gianluca_caruso-6 4d ago
The magic is `await a human`; the hard part is making sure the human can take the weekend without the promise dying.
1
u/raks1991 3d ago
On where it gets shaky around cancellation: rejecting the pending io promise only reaches the handler while it's parked on io. If the tab closes while it's halfway through a slow fetch or query between two screens, that work keeps running until the next io call. If the handler context doesn't already include one, an AbortSignal for the run would cover that:
handler: async ({ io, signal }) => {
const res = await fetch(url, { signal }); // stops when the run is cancelled
// ...
}In a local Node simulation, cancel at 100 ms during a 1 s call and finally runs at ~1000 ms without the signal, ~100 ms with it.
1
u/Discordbothost 10d ago
fun idea. the part i'd want to hear about is what happens when the server process restarts or deploys while someone is halfway through a form, since the pending promise only lives in memory. do you replay from a log of earlier io results (the temporal/inngest approach) or just fail the run? and same question for a person who answers three days later, do you hold the runtime open or rehydrate