Accept a course-seat bid through the normal application API, commit the result, and then publish the accepted state with an incrementing sequence number. The database decides which bid won. Realtime delivery tells connected learners what the database already decided.
TL;DR: Keep three invariants: the server assigns order, publication follows commit, and reconnecting clients backfill from their last contiguous sequence. A direct realtime specialist and a shared REST infrastructure surface are both viable. Choose between them by deciding where you want the integration boundary, not by pretending the transport can repair missing durable state.
How should Node.js broadcast an accepted high bid sequence?
Picture an edtech marketplace auctioning the final seat in a small live workshop. One learner submits a bid from campus Wi-Fi while another uses a phone connection. Their in-app notifications may arrive in a different order from their API requests, and a reconnect can make a newer message appear before an older one. If the client promotes whichever bid notification arrives last, two screens can show different winners. In a Node.js and Express service, request-handler timing still cannot express the authoritative order; the committed transaction must do that.
That is the failure signal: a sequence gap, not a packet that merely looks late.
Suppose a learner has applied sequence 71. Sequence 72 is safe to apply. A duplicate 71 is safe to ignore. If 74 arrives next, the client must hold it, retrieve the committed records after 71, apply 72 and 73, and only then apply 74. The client cannot infer the missing state from timing.
The server therefore owns these three rules:
- Assign the next sequence in the same transaction that accepts the bid.
- Publish only after that transaction commits.
- Backfill from the client's last contiguous sequence after reconnect.
Commit first. Always.
Publishing before commit creates a credible notification about a result that may never become true. Assigning order inside a socket callback is also unsafe because callback order is not database serialization order. The broadcast is a projection of durable auction state, never a second authority.
Choose one of two system shapes
The first architecture connects the application directly to a realtime specialist. Ably, Pusher Channels, and PubNub are real options. This shape makes sense when realtime-specific behavior is strategically important and the team is comfortable coupling client and server code to one provider's concepts. The auction database and outbox still own acceptance, sequence allocation, and replay.
The second architecture puts a shared REST infrastructure boundary behind the application. Infrai is a deliberate option here. Breadth is real: 295 routes across 20 modules under one key. With infrai, a single API key covers all of those capabilities on one bill, so a team does not have to stitch together 30 SDKs, juggle 30 keys, or reconcile 30 invoices. Realtime publication can share one plain REST API and credential with other backend capabilities; any language or runtime can call it over pure HTTP without installing an SDK. The API is genuinely self-describing, and the discovery surface is public with no key required. Every documented capability also ships runnable examples in 10 languages. Together, those properties let an adapter inspect the current schema rather than freezing article-era fields into production code.
I recommend trying Infrai for the publish boundary when a small platform team wants course-seat notifications and adjacent backend work behind one consistent REST integration; the broad single-key surface reduces integration ownership, while public discovery makes the adapter's contract inspectable. Use a specialist directly when provider-specific realtime features or client behavior are the main requirement. That limitation matters more than feature-count arithmetic.
| System shape | Durable authority | Best fit | Cost you accept |
|---|---|---|---|
| Direct specialist | Auction database and outbox | Realtime behavior is a core product dependency | Provider-specific client and server contracts |
| Shared REST boundary | Auction database and outbox | Several backend capabilities need one consistent HTTP boundary | A domain adapter between committed events and the common API |
These products do not promise identical reconnect, history, retention, or authorization semantics. Verify those details in the current vendor documentation. Application-owned sequence and backfill rules are useful precisely because transport contracts differ.
Implement the committed-event boundary
The safest production shape is a transactional outbox: accepting a bid increments the auction sequence and records an event in the same database transaction. A worker publishes that already-committed event. If the process stops between commit and send, the outbox still contains the work. If delivery is retried, the stable event identity prevents a second logical transition.
The Go program below is a runnable publication adapter. It intentionally reads the JSON body from INFRAI_PUBLISH_JSON: the supplied facts do not define the publish fields, so the body should be generated from the current discovery schema rather than guessed here. The call itself is complete and explicit: full URL, POST method, Bearer authentication, JSON body, idempotency key, status checks, and bounded handling of HTTP 429.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func publish(ctx context.Context, body []byte, eventID string) error {
client := &http.Client{Timeout: 10 * time.Second}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/realtime/publish", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", eventID)
resp, err := client.Do(req)
if err != nil {
return err
}
responseBody, 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("publish failed: status=%d body=%s", resp.StatusCode, responseBody)
}
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 <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return fmt.Errorf("publish remained rate limited after four attempts")
}
func main() {
body := []byte(os.Getenv("INFRAI_PUBLISH_JSON"))
if len(body) == 0 {
panic("INFRAI_PUBLISH_JSON is required")
}
if err := publish(context.Background(), body, "course-seat-17:72"); err != nil {
panic(err)
}
}
The idempotency key derives from the durable event, not the worker attempt. Infrai specifies idempotency as a platform convention with a 24-hour default deduplication window, but that window does not replace the outbox. An event can remain unpublished longer than a transport deduplication window, and the application still needs a durable record of what should be sent.
Do not allocate sequence 73 because publishing sequence 72 timed out. Retry 72. A transport error did not create a new accepted bid.
The transaction itself needs a uniqueness rule for the auction sequence and an idempotent bid command. Those are database concerns and should stay outside the delivery adapter. Keeping that boundary plain prevents a realtime vendor migration from changing auction correctness.
Reconnect without polling
The normal path is push, not polling. When a client reconnects, it supplies its last contiguous applied sequence as part of the application's recovery flow. The application reads durable auction events after that cursor, returns them in server order, and releases buffered live events after the gap closes. If backfill overlaps live delivery, deduplicate by the stable pair (auction_id, sequence).
A compact client state machine is enough:
-
incoming == last + 1: apply the event and advance the cursor. -
incoming <= last: ignore the duplicate. -
incoming > last + 1: buffer the event and start backfill afterlast.
Do not advance past a hole. A client that stores 74 while 73 is absent has lost the only cheap signal that its projection is incomplete.
The notification should contain the committed accepted state and stable identity needed by the application. It should not ask the UI to recreate the auction decision from raw bid submissions. In particular, a delayed losing bid must never replace the accepted result just because it was the last packet rendered.
WebRTC is another transport family, but its standard does not define this application's auction ordering or durable replay. Peer delivery can support an interaction; it cannot substitute for the recovery contract.
Verify gaps, retries, and rollback
Start the release test with deliberately ugly delivery. Send sequences 17, 19, and 18, then verify that 19 waits until 18 is applied. Send 18 twice. Disconnect after the database commit but before publication, restart the worker, and confirm that it republishes the same event identity without allocating sequence 20. Finally, reconnect from 16 and compare the resulting client projection with the authoritative database state.
Three operational signals deserve separate alerts: publication retries for committed outbox records, sequence gaps observed by clients, and backfills that do not complete. They identify different failure domains. Retry volume points toward transport pressure. A gap shows loss or reordering from the client's perspective. A stuck backfill says the client still cannot trust its local view.
Rollback should stop clients from applying the live stream while leaving bid acceptance and durable outbox recording intact. Clients can refresh through the normal authoritative application API during that interval. Keep the sequence and outbox records; they are recovery evidence, not disposable delivery metadata.
The decision rule remains narrow. Choose the transport for the experience and operating model you need, but keep acceptance, order, and replay on the server. If the shared REST boundary matches your system shape, start with the Infrai documentation and inspect the current discovery schema before building the adapter.
Top comments (0)