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 immediately
  • GET /hello-async/{uuid} reports its status until it is READY or FAILED

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.

call-async-api-poll-only.js View raw
// 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:

ready-bell.js View raw
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.js and ready-bell.js in one directory.
  • Run node call-async-api-poll-only.js Alice 4, then node call-async-api.js Alice 4, and compare the times (Node.js 22 or newer).
call-async-api.js View raw
// 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.

Terminal
➜ 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

Questions, feedback or ideas about Ready Bell?

ready-bell@proton.me