Finding and deleting realtime channels nothing uses any more

Channel list plus last_published_at and member_count identifies the dead ones. A safe sweep, and why deleting a live channel is worse than keeping a dead one.

Channels accumulate. A feature ships, creates a channel per resource, gets replaced six months later, and the channels stay — because nothing deletes them and nobody looks. GET /v1/realtime/channel/list on Infrai returns every one with member_count and last_published_at, which between them tell you exactly which are dead, and DELETE /v1/realtime/channel/delete/{channel} removes one.

The sweep is easy. Not deleting something still in use is the part that needs care.

What the inventory tells you

curl -sS "https://api.infrai.cc/v1/realtime/channel/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "channels": [
      {"channel_id": "chn_8320a6cf09f3426885646a", "name": "workspace:clinic-east", "type": "presence",
       "vendor": "tencent_im", "created_at": "2026-08-25T19:21:29Z", "member_count": 0, "last_published_at": null},
      {"channel_id": "chn_80859a23cec1419f9743d7", "name": "builds:codecheck-1787698241", "type": "presence",
       "vendor": "tencent_im", "created_at": "2026-08-25T22:50:42Z", "member_count": 0, "last_published_at": null}
    ],
    "next_cursor": null
  }
}

Two fields do the work. member_count is who’s connected right now; last_published_at is when anything was last sent. A channel with zero members and a null last_published_at, created a month ago, was created by something that never used it — which is its own finding worth chasing.

Note the second name: builds:codecheck-1787698241. A per-build channel from a CI run that finished in August. That’s the shape of the problem.

Read one before you act on it

curl -sS "https://api.infrai.cc/v1/realtime/channel/get/builds:codecheck-1787698241" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Same record, one channel. Worth doing inside a sweep immediately before the delete — an inventory read from three minutes ago is long enough for a viewer to have arrived.

The sweep

import os
import re
from datetime import datetime, timezone

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"})

# Prefixes a sweep may consider. Anything outside this list is left alone: an
# allowlist fails safe, a denylist fails open the day someone adds a prefix.
EPHEMERAL_PREFIXES = ("builds:", "job:", "preview:")
MIN_AGE_HOURS = 24


def channels() -> list[dict]:
    out, cursor = [], None
    while True:
        params = {"cursor": cursor} if cursor else None
        resp = SESSION.get(f"{API}/v1/realtime/channel/list", params=params, timeout=25)
        resp.raise_for_status()
        data = resp.json()["data"]
        out += data.get("channels", [])
        cursor = data.get("next_cursor")
        if not cursor:
            return out


def age_hours(iso: str | None) -> float:
    if not iso:
        return 0.0
    when = datetime.fromisoformat(iso.replace("Z", "+00:00"))
    return (datetime.now(timezone.utc) - when).total_seconds() / 3600


def idle(channel: dict) -> bool:
    """Dead = nobody connected AND nothing published recently AND old enough that
    a just-created channel waiting for its first client is never caught."""
    if (channel.get("member_count") or 0) > 0:
        return False
    last = channel.get("last_published_at") or channel.get("created_at")
    return age_hours(last) >= MIN_AGE_HOURS and age_hours(channel.get("created_at")) >= MIN_AGE_HOURS


def sweep(apply: bool = False) -> dict:
    considered, deleted, spared = [], [], []
    for channel in channels():
        name = channel.get("name") or ""
        if not name.startswith(EPHEMERAL_PREFIXES):
            continue
        considered.append(name)
        # Re-read immediately before deleting: the inventory may be minutes old and
        # a viewer arriving in that window must not lose their channel.
        fresh = SESSION.get(f"{API}/v1/realtime/channel/get/{name}", timeout=20)
        current = fresh.json()["data"] if fresh.ok else channel
        if not idle(current):
            spared.append(name)
            continue
        if apply:
            resp = SESSION.delete(f"{API}/v1/realtime/channel/delete/{name}", timeout=20)
            if resp.status_code not in (200, 404):
                resp.raise_for_status()
        deleted.append(name)
    return {"considered": len(considered), "deleted": deleted, "spared": spared, "applied": apply}


if __name__ == "__main__":
    print(sweep(apply=os.environ.get("APPLY") == "1"))

Three safety properties in there, each earning its place. An allowlist of prefixes, so a channel your sweep has never heard of is never touched. An age floor, so a channel created seconds ago while waiting for its first subscriber survives. And a re-read immediately before the delete, because the inventory is a snapshot and people arrive.

Why deleting a live channel is the worse failure

Keeping a dead channel costs you almost nothing — channel management reports billing_class: free in discovery, so an idle channel isn’t billing you per hour. Deleting a live one drops everyone connected to it, and the symptom shows up as “the dashboard went blank” with no obvious cause.

So bias the sweep toward caution. Run it with APPLY unset for a week and read what it would have deleted; if the list contains anything you recognise, the rule is wrong.

SignalInterpretationSweep action
member_count > 0someone is connectednever delete
Recent last_published_ata publisher is alivenever delete
Null last_published_at, old created_atcreated and never useddelete, and find the creator
Ephemeral prefix, idle 24h+finished its jobdelete
Unrecognised prefixnot yours to judgereport only

Schedule it, and watch that it ran

curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "realtime-channel-sweep",
    "cron_expr": "30 4 * * *",
    "task": "https://ops.example.com/hooks/realtime-sweep",
    "timeout_seconds": 300,
    "on_failure_webhook": "https://ops.example.com/hooks/cron-failed"
  }'

GET /v1/cron/runs/list/{id} shows whether it actually fired. A cleanup job that stopped running looks identical to a system with no cleanup, and both are discovered the same way — much later.

Better: don’t create them per resource

The real fix is upstream. A channel per CI build is thousands of channels a year; a single builds:all channel with the build id inside the payload is one channel and a client-side filter, and it never needs sweeping.

Reserve per-resource channels for cases where the subscriber set genuinely differs — a document only its collaborators watch — and use one shared channel with typed payloads for everything broadcast.

Limitations

There’s no TTL on a channel: nothing expires by itself, so without a sweep or a better naming strategy the list only grows. There’s also no “last subscriber left at” timestamp, so member_count is a point-in-time reading rather than history — a channel used heavily every Monday looks idle on a Wednesday, which is exactly why the age floor and the prefix allowlist matter.

Ably and Pusher both expose richer channel occupancy history and webhooks for occupancy transitions, so if channel lifecycle is something you need to reason about in detail rather than sweep periodically, that’s a fair reason to prefer one of them. Here the same job can subscribe to realtime.channel.vacated via POST /v1/account/webhooks/register and act on the transition instead of polling — one credential for the sweep, the schedule, the webhook and the alert, with the whole cost visible in GET /v1/account/usage (verified 2026-09-21, and those rates drift downward over time).

References

Browse more realtime developer guides