Ready Bell · async/await
An Async API in One Function
A slow job exposed through a REST API can be called in one line of code:
const result = await callAsyncApi("Alice", 7). This article shows how to
build that interface with polling and how Ready Bell makes it more responsive while
reducing the number of requests.
1. Async Abstraction over REST Polling
The hello-async API at demo.ready-bell.com is asynchronous:
POST /hello-async/{uuid}starts a job and returns immediatelyGET /hello-async/{uuid}reports its status until it isREADYorFAILED
In Node.js, an async function can hide the polling behind a simple
interface: submit the job, then loop — sleep, check, and return the result once the
job finishes. The caller just awaits the result, without having to deal with polling.
// Usage: node call-async-api-poll-only.js [name] [dbg-delay-sec]
import { setTimeout as sleep } from "timers/promises";
const API = "https://demo.ready-bell.com/hello-async";
const POLL_INTERVAL_MS = 3000;
const TIMEOUT_MS = 60000;
const [name = "Alice", delaySec = "4"] = process.argv.slice(2);
const started = performance.now();
const result = await callAsyncApi(name, Number(delaySec));
console.log(result);
console.log(`callAsyncApi took ${((performance.now() - started) / 1000).toFixed(3)} s`);
async function callAsyncApi(name, delaySec) {
const uuid = crypto.randomUUID();
const deadline = Date.now() + TIMEOUT_MS;
await submitAsyncJob(uuid, name, delaySec);
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS);
const job = await fetchFinishedJob(uuid).catch(err => console.log(err.message));
if (job) return job;
}
throw new Error(`Timed out after ${TIMEOUT_MS} ms`);
}
function submitAsyncJob(uuid, name, debugDelay) {
return fetch(`${API}/${uuid}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { name, "dbg-delay-sec": debugDelay } }),
}).then(res => {
if (!res.ok) throw new Error(`POST failed: HTTP ${res.status}`);
});
}
function fetchFinishedJob(uuid) {
return fetch(`${API}/${uuid}`).then(res =>
res.ok ? res.json() : Promise.reject(new Error(`GET failed: HTTP ${res.status}`))
).then(job =>
job.status === "IN_PROGRESS" ? Promise.reject(new Error("Job IN_PROGRESS")) : job
);
}
The poll interval is a trade-off. Poll rarely and the result waits for the next check;
poll often and most requests only learn that the job is still in progress. With a
3-second polling interval, a job that finishes in 4 seconds won't be detected until the
second GET request, so the async function returns the result at least
2 seconds later than it becomes available.
2. The ReadyBell Class as a Replacement for sleep()
The ReadyBell class provides readyBell.wait(uuid, ms) as a replacement for
sleep(). It sends Listen <uuid> over UDP and resolves
when either the timeout expires or a matching Ready <uuid>
notification arrives, whichever comes first. Notifications received while no
wait is active — for example, during a GET request — are
stored in a backlog, so the next wait resolves immediately.
The implementation is straightforward:
import dgram from "dgram";
const BACKLOG_TTL_MS = 60000;
export class ReadyBell {
waiters = new Map();
backlog = new Map();
socket = dgram.createSocket("udp4");
constructor() {
this.socket.on("message", msg => this.onMessage(msg.toString().trim()));
this.socket.unref();
}
sendListen(uuid, seconds) {
this.evictStale();
this.socket.send(`Listen ${uuid} ${seconds}`, 3137, "ready-bell.com");
}
sendNotify(uuid) {
this.socket.send(`Notify ${uuid}`, 3137, "ready-bell.com");
}
wait(uuid, ms) {
if (this.backlog.delete(uuid)) return Promise.resolve();
this.sendListen(uuid, 2 * ms / 1000);
return new Promise(resolve => {
const wakeUp = () => {
clearTimeout(timer);
this.waiters.delete(uuid);
resolve();
};
const timer = setTimeout(wakeUp, ms);
this.waiters.set(uuid, wakeUp);
});
}
onMessage(text) {
console.log(text);
const [command, uuid] = text.split(" ");
if (command !== "Ready") return;
this.evictStale();
const wakeUp = this.waiters.get(uuid);
if (wakeUp) wakeUp();
else this.backlog.set(uuid, Date.now() + BACKLOG_TTL_MS);
}
evictStale() {
for (const [uuid, expires] of this.backlog) {
if (expires < Date.now()) this.backlog.delete(uuid);
}
}
}
3. Async Abstraction over Event-Driven Polling
The callAsyncApi function from the first section can be adapted to use
the ReadyBell class from the second to make the asynchronous REST API more
responsive. Its interface stays the same: callers still just await the
function, without knowing whether it sleeps or waits for a notification. The
highlighted lines are the ones that changed compared to the first section: they
import ready-bell.js, subscribe before the POST so an early
Ready isn't missed, call readyBell.wait() instead of
sleep(), and raise the polling interval. Polling remains as a fallback
because UDP doesn't guarantee delivery, but since the notification normally arrives
first, the interval can now be increased to 15 seconds. The same 4-second job now
takes about 4.5 s and requires only one GET.
- Put
call-async-api.jsandready-bell.jsin one directory. - Run
node call-async-api-poll-only.js Alice 4, thennode call-async-api.js Alice 4, and compare the times (Node.js 22 or newer).
// Usage: node call-async-api.js [name] [dbg-delay-sec]
import { ReadyBell } from "./ready-bell.js";
const API = "https://demo.ready-bell.com/hello-async";
const POLL_INTERVAL_MS = 15000;
const TIMEOUT_MS = 60000;
const readyBell = new ReadyBell();
const [name = "Alice", delaySec = "4"] = process.argv.slice(2);
const started = performance.now();
const result = await callAsyncApi(name, Number(delaySec));
console.log(result);
console.log(`callAsyncApi took ${((performance.now() - started) / 1000).toFixed(3)} s`);
async function callAsyncApi(name, delaySec) {
const uuid = crypto.randomUUID();
const deadline = Date.now() + TIMEOUT_MS;
readyBell.sendListen(uuid, 30);
await submitAsyncJob(uuid, name, delaySec);
while (Date.now() < deadline) {
await readyBell.wait(uuid, POLL_INTERVAL_MS);
const job = await fetchFinishedJob(uuid).catch(err => console.log(err.message));
if (job) return job;
}
throw new Error(`Timed out after ${TIMEOUT_MS} ms`);
}
function submitAsyncJob(uuid, name, debugDelay) {
return fetch(`${API}/${uuid}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { name, "dbg-delay-sec": debugDelay } }),
}).then(res => {
if (!res.ok) throw new Error(`POST failed: HTTP ${res.status}`);
});
}
function fetchFinishedJob(uuid) {
return fetch(`${API}/${uuid}`).then(res =>
res.ok ? res.json() : Promise.reject(new Error(`GET failed: HTTP ${res.status}`))
).then(job =>
job.status === "IN_PROGRESS" ? Promise.reject(new Error("Job IN_PROGRESS")) : job
);
}
Output of both runs. Job IN_PROGRESS is the message of the error from
fetchFinishedJob. Registered and Ready are UDP
packets received from ready-bell.com:3137.
➜ node call-async-api-poll-only.js Alice 4
Job IN_PROGRESS
{
id: 'c9c60f09-91f6-4870-8032-6d48530cd085',
created: '2026-10-27T16:24:21.490454Z',
modified: '2026-10-27T16:24:25.490687Z',
status: 'READY',
input: { name: 'Alice' },
output: { greeting: 'Hello, Alice!' },
error_message: null
}
callAsyncApi took 6.699 s
➜ node call-async-api.js Alice 4
Registered 11fa5281-08f2-47df-953e-645024008f5c 203.0.113.10 50224 30
Registered 11fa5281-08f2-47df-953e-645024008f5c 203.0.113.10 50224 30
Ready 11fa5281-08f2-47df-953e-645024008f5c
{
id: '11fa5281-08f2-47df-953e-645024008f5c',
created: '2026-10-27T16:24:33.820150Z',
modified: '2026-10-27T16:24:37.820392Z',
status: 'READY',
input: { name: 'Alice' },
output: { greeting: 'Hello, Alice!' },
error_message: null
}
callAsyncApi took 4.544 s