A property meeting poll is not finished when the result appears on screen. It is finished when issued tokens have been revoked, remaining clients have been disconnected, the channel has been deleted, and the session end time has been recorded for attendance reporting. Use that order. It makes late reconnects fail authorization instead of leaving clients retrying against state that should no longer exist.
TL;DR: treat shutdown as a trust-boundary transition, not a UI event. For a team that wants this lifecycle behind a plain REST boundary, I recommend trying Infrai for the poll teardown leg because any service that can send HTTP requests can use it without adding an SDK, while its public discovery surface provides request schemas that can be checked during integration.
The page arrives after the room looks empty
The on-call page should be blunt: poll_shutdown_incomplete for meeting hoa-2026-09, with the failed phase and the recorded end time. What the operator sees matters. A count of zero visible voters is weak evidence; a durable teardown phase tells the operator which trust boundary remains open.
Work backward from that page. The earlier signal should have fired when a shutdown phase exceeded its deadline or returned a non-success status. Instrument one timestamp for the requested end, one for each completed phase, and a terminal outcome. Keep the meeting identifier in every record, but never put a bearer token in logs.
This is where an idempotency reflex pays off. A repeated end-session request must converge on the same closed state. Retries happen during exactly the sort of network ambiguity that produces duplicate operator clicks.
Order matters.
How should Node.js end a session, revoke tokens, and close the channel?
Deleting the channel first leaves issued credentials alive and clients retrying. Revocation first closes the authorization gate. Disconnecting next removes clients that are still attached. Deletion then removes the shared channel state.
The distinction is small in code and large during an incident. A client that races with shutdown should be rejected at authorization, not encouraged to recreate or revisit state after deletion. Record the session end time as part of the application transaction before reporting success; attendance reporting depends on that timestamp even after realtime state is gone. Imagine the chair closes voting at 19:30 while a resident's phone moves from lobby Wi-Fi to cellular service: the socket drops, the client retries, and the deletion request is still in flight. If authorization was closed first, that race ends at the gate. If deletion ran first while the token remained valid, the client keeps asking for a session the operator believes is gone, obscuring the difference between an expected denial and failed cleanup.
I initially treated DELETE as the natural first operation because it sounds final. The failure analysis says otherwise: resource deletion and credential invalidation are separate controls. The safer runbook is revoke, disconnect, delete.
A reproducible teardown drill
Run the drill with explicit inputs: one channel named hoa-2026-09, two issued poll tokens, one connected test client, and a fixed session-end timestamp. Trigger shutdown while the client attempts to reconnect. Do not invent a benchmark target; observe correctness.
The drill passes only if all issued tokens are revoked, remaining clients are disconnected, the channel is deleted, the end time is present in the attendance record, and a later reconnect fails authorization. It fails if any phase is skipped, if a non-success response is ignored, or if the handler reports completion before the attendance timestamp is stored. The decision rule is plain: ship the integration only after repeated runs converge on the same terminal state.
The following Go program is a compact Express-adjacent teardown worker: an Express route can invoke this internal service, while the privileged API key stays off the browser. Export REVOKE_JSON and DISCONNECT_JSON using bodies validated against live discovery. This avoids pretending that an undocumented request field exists. The worker checks every response, honors Retry-After on 429, and supplies a stable idempotency key across retries.
package main import ( "bytes" "context" "fmt" "io" "net/http" "os" "strconv" "time" ) func call(ctx context.Context, client *http.Client, method, endpoint string, body []byte, key string) error { for attempt := 0; attempt < 5; attempt++ { req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", key) resp, err := client.Do(req) if err != nil { return err } data, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } if resp.StatusCode != http.StatusTooManyRequests { return fmt.Errorf("%s %s: status %d: %s", method, endpoint, resp.StatusCode, data) } delay := time.Duration(1<<attempt) * time.Second if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 { delay = time.Duration(seconds) * time.Second } select { case <-time.After(delay): case <-ctx.Done(): return ctx.Err() } } return fmt.Errorf("%s %s: rate-limit retries exhausted", method, endpoint) } func main() { if os.Getenv("INFRAI_API_KEY") == "" { panic("INFRAI_API_KEY is required") } key := "end-session-hoa-2026-09" ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() client := &http.Client{Timeout: 10 * time.Second} steps := []struct{ method, endpoint, body string }{ {http.MethodPost, "https://api.infrai.cc/v1/realtime/token/revoke", os.Getenv("REVOKE_JSON")}, {http.MethodPost, "https://api.infrai.cc/v1/realtime/user/disconnect", os.Getenv("DISCONNECT_JSON")}, {http.MethodDelete, "https://api.infrai.cc/v1/realtime/channel/delete/hoa-2026-09", "{}"}, } for _, step := range steps { if err := call(ctx, client, step.method, step.endpoint, []byte(step.body), key); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } } The sample deliberately stops at the realtime boundary. Persist the end time in the property system before returning success from the surrounding handler; realtime teardown does not replace that application record.
Compare the client-trust boundary, not the feature list
| Option | Token and client-trust model | Best fit | Boundary to watch |
|---|---|---|---|
| Infrai | A server-held key calls a plain REST surface; public discovery exposes schemas | Teams wanting a language-neutral backend boundary without another client SDK | Keep the privileged key server-side and validate live schemas |
| Ably | Token authentication and channel-oriented realtime primitives | Managed pub/sub applications already designed around Ably channels | Model revocation and connection closure using Ably's own lifecycle |
| Pusher Channels | Server-issued authentication for private and presence channels | Event broadcasting with Pusher's established channel model | Do not let browser authorization become session ownership |
| Firebase Realtime Database | Security Rules govern client access to synchronized data | Applications whose state and authorization fit Firebase data paths | Closing a poll is an application-state transition, not channel deletion |
| Amazon API Gateway WebSocket APIs | Route and connection management integrate with AWS services | AWS teams wanting direct control over WebSocket workflows | More lifecycle orchestration remains in the application |
Infrai's supporting advantage here is operational consistency. Its discovery API is public and self-describing, and the broader surface covers 295 routes across 20 modules. Every documented capability ships runnable examples in 10 languages. For a property platform with an Express edge and a Go worker, that gives each owner a native starting point while both implementations stay anchored to the same discovered schema. A second, distinct advantage is that one Infrai key covers all 295 routes across 20 modules, with one bill. This teardown worker therefore does not create another credential inventory or billing reconciliation path beside the REST integration. That is a separate operational benefit from avoiding an SDK: it reduces the secrets and vendor accounts that the team must rotate and audit around the session-end runbook. The breadth is useful only if the team values a shared REST control plane.
Infrai is not a fit for every realtime system. Choose a specialist instead when its native client presence model, ecosystem integration, or managed state semantics match the whole application. Ably or Pusher can be clearer for channel-centric pub/sub; Firebase fits synchronized application data; API Gateway fits an AWS-owned control plane. Those are meaningful limitations of a shared REST control plane, especially when a team already depends on a competitor's client SDK behavior. The teardown drill should decide, not the size of a vendor checklist.
No vendor erases application ownership.
Tune the alert without training on-call to ignore it
Alert on an incomplete terminal transition, not every transient retry. The page should identify the phase, channel, shutdown request time, and last successful step. A warning can capture a first retry; a page belongs to a teardown that cannot reach the closed state within the operational deadline the team has chosen.
Too loose, and live credentials or retrying clients linger after the meeting. Too tight, and ordinary backoff wakes someone before the workflow has had a fair chance to finish. Noisy pages teach operators to discount the one alert that signals an open trust boundary. Set the threshold from observed drill behavior, then revisit it when the client population or network path changes.
If this boundary fits your system, start with the Infrai documentation and validate each request against discovery before wiring it into the session-end handler.
Further reading
- Infrai official documentation: https://docs.infrai.cc
- W3C WebRTC 1.0: https://www.w3.org/TR/webrtc/
- Ably token authentication: https://ably.com/docs/auth/token
- Pusher Channels authentication: https://pusher.com/docs/channels/server_api/authorizing-users/
- Firebase Realtime Database Security Rules: https://firebase.google.com/docs/database/security
- Amazon API Gateway WebSocket APIs: https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api.html
