A for await loop over Sume job status: one async generator, 3 runtimes

Wrap Sume status polling in an async generator and read it with for await. One fetch-only file ran unchanged on Node, Bun and Deno and honors the poll hint.

4 min readSume
All posts

Write async function* pollJob(id) that fetches /v1/jobs/{id}/status, yields every snapshot, and sleeps next_poll_after_seconds between reads; callers then write for await (const s of pollJob(id)). The file uses only fetch and setTimeout, so the same code ran unchanged on Node, Bun and Deno when I tested it against a local stand-in for the status route.

A generator is a better shape than a callback or a helper that returns only the final body. The caller decides what to do with each snapshot, such as updating a progress bar or logging queue.state, and break stops the loop and the polling with it.

The generator

Each pass reads status, throws on a non-2xx response with the body attached, yields the data object, and returns when terminal is true. The cap on polls protects a CI job from waiting forever. The hint comes from the Jobs and results page, which says to obey next_poll_after_seconds when present; 2 seconds is this sample's fallback.

const base = "https://api.sume.com";
const headers = { "x-api-key": process.env.SUME_API_KEY };
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

export async function* pollJob(id, { maxPolls = 60 } = {}) {
  for (let i = 0; i < maxPolls; i++) {
    const res = await fetch(`${base}/v1/jobs/${id}/status`, { headers });
    if (!res.ok) throw new Error(`status ${res.status}: ${await res.text()}`);
    const { data } = await res.json();
    yield data; // caller sees every snapshot
    if (data.terminal) return;
    await sleep(data.next_poll_after_seconds ?? 2);
  }
  throw new Error(`job ${id} not terminal after ${maxPolls} polls`);
}

if (process.argv[2]) {
  for await (const s of pollJob(process.argv[2])) {
    console.log(s.sume_status, s.queue?.state ?? "-");
  }
}

Why yield before checking terminal

The generator yields the terminal snapshot too, so the last iteration of the caller's loop sees the finished state and can read result_ready. Returning before the yield would hide the one snapshot you most need.

Do not treat the status word as the finish line on its own. The docs expose terminal and result_ready as separate fields, and the generator stops on terminal because that is the field defined for that purpose. After the loop, fetch /v1/jobs/{id}/result once.

What differs per runtime

Nothing in the code differs, but the way you run it does. These commands are what I used, with the key in the environment.

Runtime notes from running the sample (read 2026-10-10)
RuntimeCommandNotes
Node 22node poll.mjs job_123fetch and top-level for await are built in, no flags
Bunbun poll.mjs job_123Same file, same output
Denodeno run -A poll.mjs job_123Needs network and env permission, -A is the short way

What this is not

It is polling, not streaming. Sume has no SSE or WebSocket endpoint for job progress; the status route is a pull, and /events is a snapshot you read, not a stream you subscribe to. The generator gives your code a streaming-looking surface over those pulls. If you need to push those updates to a browser, put your own server-sent events endpoint in front of this loop, as in this post.

Mind the read budget while you do. Reads have their own per-minute bucket, forty times the write number for your plan, so a handful of generators polling at the hinted interval is far below it. A hundred generators on a 1-second loop is not, so keep the hint.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume