8,822 calls to GET /v1/tickets and 8,815 calls to GET /v1/events in three days. Together they were 88% of every request my tracker's API served, from an app used by one person and two agents.
The constraint: the agents have to notice new work within seconds, and an idle agent should cost nothing.
I'm Panth, and I lead the software team at Oizom. This is from ticket-tracker, my open-source board, which my agents work from. Its plan has a section on what the app costs, and this post is the part of it about the agents: they used to poll, and now they wake.
What the polling cost
The numbers are from 23 to 26 September 2026.
| What | Measured |
|---|---|
| Requests to the API function | 41,515 in 3 days |
| Billable vCPU-seconds that were the API | 11,963 of 13,775 (87%) |
| Orchestrator polling | 8,822 tickets + 8,815 events = 88% of requests |
| Time per call | about 250 ms |
Two orchestrator loops ran every 30 seconds or so. Each one asked for the full list every time: tickets with limit=200 and no updated_since, events with unacked=1 and no cursor. The answer was the same almost every time, and it was paid for every time.
The API runs on Cloud Run, which bills CPU for the whole request, including the time it spends waiting on I/O. Each call did an API-key lookup, a board read, an agent read and two rate-limit transactions before the actual work. An idle agent was the largest line on the bill.
The shape that costs nothing while idle
The API's data is in Firestore, which bills per operation. The Realtime Database bills for bandwidth. So the signal lives in the Realtime Database, and the data stays where it is.
Two tiny nodes carry the signal:
rev/{boardId} 'something on this board changed'
agents/{agentId}/wake 'something is in your inbox'
Every write goes through one command layer. After a command succeeds, it overwrites the board's rev node with about 40 bytes:
export const bumpBoardRev = (boardId: string, by: string | null, at: number): Promise<void> =>
liveSafe(`rev ${boardId}`, () =>
rtdbAdmin()
.ref(live.rev(boardId))
.set({ at, ...(by ? { by } : {}) }),
);
Nothing about the change travels through it. The node only says "ask again". Two commands don't move it: ping, which changes nothing, and the agent heartbeat, which has its own node. Bumping on a heartbeat would wake every watcher of the board once a minute per agent, which is the polling all over again.
The agent's side is one long-lived stream on those nodes, then one REST call that asks only for what changed: updated_since for tickets, a cursor for events. One Realtime Database write per command, one small query per real change, and nothing at all while the board is quiet.
Streaming without the Firebase SDK
The Realtime Database's REST API streams a node as plain Server-Sent Events. Ask for {database_url}/{path}.json with accept: text/event-stream and you get put and patch frames as the node changes. There is no Firebase SDK in the SDK, so the same code runs in Node, Deno and Bun. An idle stream is an open socket and close to zero bytes.
The stream needs a credential, and an agent holds an API token, not a Firebase login. GET /v1/live bridges that. It mints a Firebase custom token for the token's principal, with a board claim for an agent's board, exchanges it server-side for an ID token, and returns the database URL, the token, its lifetime and the exact paths it may stream. The database rules then let an agent read its own wake node and its board's rev, and nothing else. The watcher re-mints when the credential expires.
Two details that keep it from becoming a poll again
A reconnect is not a change. The Realtime Database replays the node's current value as a put every time a stream connects. If every frame woke the agent, a flapping connection would be a polling loop with extra steps. So a frame only counts when its at timestamp has moved past the last one seen, and the first frame after a connect only records where things stand.
Ten bumps are one fetch. The watcher keeps at most one wake queued. If ten commands land while the agent is busy, it wakes once and makes one delta fetch, because the delta covers all ten. A real change replaces a waiting backstop wake, never the other way round.
When the stream can't open
Some setups can't hold an SSE stream: a proxy that eats it, a runtime without streaming bodies, an older server without /v1/live. Then the watcher polls, and says why. The poll starts at 2 seconds while work is arriving and backs off to 60 seconds when nothing is found. The caller tells it which, with the number of rows the last delta returned:
const w = tm.watch();
for await (const signal of w) {
const page = await tm.events.list({ cursor, unacked: true });
cursor = page.next_cursor ?? cursor;
for (const ev of page.data) handle(ev);
w.found(page.data.length); // 0 ⇒ back off, >0 ⇒ stay eager
}
Even while streaming, the SDK fetches once every 15 minutes in case a bump was lost. That backstop is the only REST traffic an idle streaming watcher makes.
What it was for
The target in the plan is 50 boards with 10 live tickets each, always on, for 50 rupees a month or less. That only works if the steady state sits inside Firestore's free daily quota and the API functions stay off the read path. Agents that poll every 30 seconds can't fit in that. Agents that wake on a 40-byte write can.
If your workers poll an API on a timer, what share of your requests is them asking whether anything changed?
Top comments (0)