Pushing updates to a browser from a background worker over REST
A worker with no socket connection publishes over plain HTTP. The call, the event naming that keeps clients simple, and what publish does not guarantee.
A background worker has no browser connection and shouldn’t need one. On Infrai it publishes with a single HTTP call — POST /v1/realtime/publish with a channel, an event name and a data payload — and every subscribed client gets it. The worker stays a plain process that makes a POST when something finishes.
That’s the whole integration. The interesting decisions are about naming and about what publish does not promise.
The call
curl -sS -X POST "https://api.infrai.cc/v1/realtime/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"channel": "job:export-4821",
"event": "message.published",
"data": {"status": "complete", "rows": 18422, "download_path": "/exports/4821.csv"}
}'
{
"ok": true,
"data": {
"event_id": "evt_2fVc8nRqLmT4xBzY",
"channel": "job:export-4821",
"published_at": "2026-09-21T03:07:11Z",
"vendor": "tencent_im"
}
}
event_id is worth logging alongside your job id — when a user says the progress bar stopped, having the publish receipt tells you whether the worker did its part.
The event value comes from a closed set you can read at runtime:
curl -sS "https://api.infrai.cc/v1/realtime/event/types" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"types": ["channel.closed", "channel.opened", "message.published", "presence.join", "presence.leave"]
}
}
So the transport event is message.published and your own semantics live inside data. Put a type field in there — {"type": "export.complete", ...} — and your client gets one subscription with a switch statement rather than a growing list of channel names.
Channel per resource, not per user
The naming decision that saves you later: name channels after the thing being watched, not the person watching.
job:export-4821 is watched by whoever opened that export — one, three, or a whole support team. user:au_usr_lMmXG… needs the worker to know the audience before it can publish, which means a lookup the worker shouldn’t have to do.
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def publish(channel: str, payload: dict) -> str:
"""Fire-and-forget progress from a worker. Publishing is best-effort by design:
a client that wasn't connected does not receive it later, so anything the user
must not miss also needs a durable record."""
resp = SESSION.post(
f"{API}/v1/realtime/publish",
json={"channel": channel, "event": "message.published", "data": payload},
timeout=10,
)
body = resp.json()
if not body.get("ok"):
# A failed publish must never fail the job. Record it and move on.
return f"publish_failed:{body.get('error', {}).get('code')}"
return body["data"]["event_id"]
def run_export(job_id: str, rows: list[dict]) -> dict:
channel = f"job:{job_id}"
publish(channel, {"type": "export.started", "total": len(rows)})
written = 0
for index, _row in enumerate(rows, start=1):
written += 1
# Publish progress on a stride, not per row: a hundred thousand rows is a
# hundred thousand publishes and a client that spends its time rendering.
if index % 500 == 0:
publish(channel, {"type": "export.progress", "done": index, "total": len(rows)})
event_id = publish(channel, {"type": "export.complete", "rows": written})
return {"rows": written, "final_event_id": event_id}
if __name__ == "__main__":
print(run_export("export-4821", [{"id": i} for i in range(1200)]))
The stride matters more than it looks. Publishing per row turns a fast job into a slow one and a smooth progress bar into a stuttering one.
What publish doesn’t guarantee
This is the part to design around rather than discover.
A publish reaches clients that are connected now. It isn’t a queue, there’s no replay for a client that was reconnecting, and no acknowledgement that a human saw it. For progress bars and live counters that’s exactly right — stale progress is worthless anyway.
For anything the user must not miss, publish is the fast path and something durable is the truth. Write the result where the client can fetch it on reconnect, and treat the realtime event as a hint that it’s worth fetching.
| Use case | Publish alone | Publish + durable record |
|---|---|---|
| Progress bar | fine | unnecessary |
| Live dashboard counter | fine | unnecessary |
| ”Your export is ready” | not enough | yes — the job record |
| Chat message | not enough | yes — stored history |
| Payment confirmed | not enough | yes, and don’t rely on the event |
Wire the worker to the queue it drains
The shape that works end to end: the API enqueues, the worker consumes, and the worker publishes progress as it goes.
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue": "exports", "payload": {"job_id": "export-4821", "rows": 18422}}'
Because the queue and the realtime fan-out are the same credential, the worker holds one secret and the whole pipeline shows up in one GET /v1/account/usage. With a separate realtime vendor that’s a second token in the worker’s environment, a second dashboard when messages go missing, and a second invoice — for what is architecturally one feature.
Limitations
There’s no server-side history on a channel: history exists as a token capability for clients that support it, but this API doesn’t expose a “give me the last N events” read, so your reconnect path is a fetch from your own store rather than a replay.
The fan-out vendor today is tencent_im, with Ably and Pusher pending — check GET /v1/discovery/realtime.publish for live readiness rather than assuming, and if your users are concentrated somewhere that vendor serves poorly, that’s a real consideration. Pusher and Ably also ship far more mature browser clients with reconnection and backoff built in; if the client-side experience is the hard part of your problem, one of them is the better tool.
Publishing is billed per call at a rate live in GET /v1/discovery/realtime.publish (verified 2026-09-21) — small enough that the stride in the code above matters more than the rate does — while channel and token management report billing_class: free. Those rates move downward as vendor contracts improve, so read them rather than quoting this page.