SFHooks Blog

Jul 28, 2026 · Engineering Team

Six things nobody tells you about the Salesforce Pub/Sub API

The quickstart is not the product

The Salesforce Pub/Sub API is good. The design is sound, the docs are decent, and a happy-path subscriber takes an afternoon. We run CDC subscriptions for many orgs as our core product, and everything below is a failure mode the quickstart never mentions. Each one was found the hard way, in production. If you're building a subscriber, treat this as the missing appendix.

1. Open and read the stream in the same process

The subtle one first, because getting it wrong doesn't error. With gun (the Erlang HTTP/2 client under grpc-elixir), inbound frames for a stream are routed to the process that opened that stream, and GRPC.Stub.recv/2 does a selective receive in whatever process calls it. Open the Subscribe stream in one process and read it from another and every event is silently dropped. No crash, no error tuple. The subscriber looks healthy and receives nothing, forever.

Our rule: the GenServer that owns a subscription opens the stream, reads the stream, and does nothing else. Any architecture where a "connection manager" opens streams and hands them to workers is quietly broken on this transport.

2. Idle connections die silently, so ping them

A CDC stream for a quiet org can sit idle for long stretches with no record changes and no frames. gun 2.x defaults HTTP/2 keepalive to :infinity: no PINGs are ever sent. A NAT gateway or load balancer that drops the idle connection leaves you blocked in a recv that will never return, on a connection that no longer exists. Nothing errors. You find out when someone asks why yesterday's changes never arrived.

We set a 30-second HTTP/2 ping and treat it as non-negotiable.

3. The 270-second heartbeat is your liveness signal

Salesforce emits an empty keepalive FetchResponse roughly every 270 seconds even when no events flow. That cadence is the basis for the only reliable liveness check we've found: if the stream has been silent longer than ~330 seconds, it's dead, whatever the socket claims. Our watchdog kills and resubscribes at that threshold. Belt (ping) and suspenders (watchdog), because each catches deaths the other misses.

4. Don't reset your backoff on subscribe success

The trap: Salesforce accepts a subscribe call with bad credentials and only then closes the stream. If your reconnect logic resets its exponential backoff when the subscribe call succeeds (the obvious place to do it), a revoked or expired token turns your polite backoff into a ~1-second hammer against Salesforce, forever.

Reset backoff only when a pushed response, meaning a real event or a keepalive, proves the stream is actually healthy. Never on the subscribe round-trip itself. And after a few consecutive auth rejections, stop entirely and surface the connection as broken: retrying a dead credential helps no one.

5. The replay-ID error message contains the word "valid"

Replay IDs expire (the bus retains events for ~72 hours) and can be invalidated by maintenance. Salesforce rejects a dead replay ID with gRPC status 3 (INVALID_ARGUMENT) and this message, observed live:

text
The Replay ID validation failed. Ensure that the Replay ID is valid. rpcId: ...

Note what it says: "valid". A classifier that greps failure text for "invalid" (exactly what we wrote first) misses it, and the subscriber reconnect-loops on the dead ID forever. Match on the status code plus "Replay ID", not on failure-sounding words.

And when you do reset to latest, record what you did: the gap between the last checkpoint and the reset is a window of possibly-missed events. Resuming silently is data loss with no witness. We write a gap incident, bound it by the checkpoint's last advance, email the org's admins, and show it in the UI.

6. Gap events arrive with no data in them

Under certain conditions the bus emits GAP_CREATE / GAP_UPDATE / OVERFLOW events that carry only record IDs, with no field data. If you forward them as-is, your consumers receive empty changes. The correct handling is reconciliation: re-fetch each record over the REST API and merge the live state into the event, with its own error path. A failed fetch should record a failed event, not deliver an empty body. And GAP_DELETE needs no fetch, since there's nothing left to read.

This is also where your API-limits story starts mattering: gap reconciliation spends REST calls, so it needs a validated token, per-connection serialization, and restraint.

The actual moral

None of these is exotic. Every one is ordinary distributed-systems weather, and every one defaults to silent data loss rather than a crash. That's the real cost of a DIY subscriber: not the week to first event, but the months of discovering which silences matter.

  • Building anyway? Fair enough. Start from the full checklist.
  • Rather not own this? This subscriber, pings and watchdog and gap reconciliation included, is the thing we sell, delivered to you as signed JSON webhooks.

Verify a payload right now

No account needed: the playground shows a live delivery and computes its HMAC signature in your browser.