Pub/Sub API vs Streaming API (CometD/EMP): Ordering & At-Least-Once Delivery in Salesforce

Share

Salesforce provides two powerful ways to move events in and out of the Event Bus — the modern Pub/Sub API and the Streaming API family (CometD/EMP).

Both deliver events with at-least-once semantics and support replay, but they differ in transport protocols, performance, payload formats, and operational fit. Understanding these differences helps you design resilient, high-throughput integrations that maintain event order and handle duplicates safely.


⚙️ Overview

Need / Trait Pub/Sub API Streaming API (CometD/EMP)
Transport gRPC bidirectional streaming (or REST) CometD (Bayeux) over HTTP long-poll
Payload Protobuf (binary), schema registry; highly efficient JSON; human-readable and easy to inspect
Throughput & Flow Control High throughput with back-pressure and acknowledgements Suitable for light/medium traffic; fewer tuning options
Publish Support ✅ Yes (produce & consume) ❌ Subscribe only (PE/CDC/PB/Generic)
Replay Modes EARLIEST / LATEST / CUSTOM (ReplayId) Same via Replay Extension
Libraries gRPC clients in multiple languages EMP Connector (Java), CometD JS, Bayeux clients
Operational Fit Ideal for microservices, pipelines, strict SLAs Ideal for admin-friendly, low-maintenance subscribers

? When to Use Which

  • Use Pub/Sub API when you need publish + subscribe, binary efficiency, flow control, or high-volume integrations with strict SLAs.

  • Use Streaming API (CometD/EMP) when you want quick JSON-based subscribers, CDC listeners, or lightweight browser/Java consumers.

? Rule of Thumb:

“If you need typed payloads, back-pressure, and throughput — use Pub/Sub API.
If you need simple, JSON-friendly listeners — use CometD/EMP.”


? Ordering & At-Least-Once Delivery (What You Really Get)

Both APIs deliver messages at least once, not exactly once.
That means duplicates can happen — due to retries, reconnects, or replay.

To keep your processing safe:

  • Always design idempotent consumers.

  • Persist replay checkpoints.

  • Partition ordering by logical business keys.

Delivery Semantics

  • At-Least-Once: Duplicates are possible. Your consumer must handle them safely.

  • Ordering: Salesforce does not guarantee global ordering.

    • Within a transaction, CDC events include ChangeEventHeader with transactionKey and sequenceNumber — you can locally reorder using these.

    • Across transactions, order may vary. To avoid cross-key chaos, partition by a key like AccountId or OrderId.

Replay

Both APIs support:

  • LATEST

  • EARLIEST

  • CUSTOM (from stored ReplayId)

Best practice: Store checkpoints after each successful processing cycle.


? Idempotency & Deduplication

Since events may be re-delivered, you must make your consumers idempotent — safe to reprocess the same message multiple times.

Key pattern: Claim-Then-Act

  • Build a unique deduplication key, e.g.:
    ${RecordId}:${transactionKey}:${sequenceNumber} for CDC, or
    DedupKey for Platform Events.

  • Insert this key into a unique storage record (e.g., Idempotency__c.Key__c).

  • If the insert succeeds → process the event.

  • If it fails (duplicate key) → skip the side effects.

Keep deduplication entries for at least the event retention period to prevent reprocessing old events.


? Real-World Example (Section-Wise)

Scenario

You operate a billing microservice that:

  • Subscribes to Platform Events (InvoiceIssued__e) using the Pub/Sub API for high volume.

  • Consumes Account CDC via CometD/EMP for cache invalidation.

  • Enforces idempotency and CUSTOM replay for reliability.


A) Pub/Sub API (Node.js-style, gRPC) — Subscribe with Back-Pressure & CUSTOM Replay

// Pseudo-code: subscribe to /event/InvoiceIssued__e with a stored checkpoint
const topic = '/event/InvoiceIssued__e';
let lastReplayId = await checkpointRepo.load(topic); // Buffer or base64

const request = {
  topicName: topic,
  replayPreset: lastReplayId ? 'CUSTOM' : 'LATEST',
  replayId: lastReplayId || null,
  numRequested: 500 // flow control: server will stream up to this many
};

const stream = await pubsubClient.subscribe(request);

for await (const message of stream) {
  const replayId = message.replayId; // binary
  const payload = decodeProtobuf(message.payload); // your POJO

  // Idempotency: claim before side effects
  const key = payload.dedupKey || `${payload.invoiceId}:${payload.version}`;
  const claimed = await idempotencyRepo.claim(key);
  if (!claimed) { await checkpointRepo.save(topic, replayId); continue; }

  await billing.applyInvoice(payload); // DB upsert, external calls, etc.
  await checkpointRepo.save(topic, replayId); // commit checkpoint last
  // Stream continues; back-pressure by awaiting work before reading next
}

Why Pub/Sub API here?

  • Binary payloads are compact and efficient.

  • Flow control prevents overload.

  • Supports both producing and consuming events.


B) Streaming API via EMP Connector (Java) — CDC with JSON & Replay

// Pseudo-code using EMP Connector to consume AccountChangeEvent
String channel = "/data/AccountChangeEvent";
Long replayFrom = checkpointRepo.load(channel).orElse(EmpConnector.REPLAY_FROM_TIP);

BearerTokenProvider tokenProvider = new BearerTokenProvider(() -> yourOAuthToken());

EmpConnector emp = new EmpConnector(new BayeuxParameters(tokenProvider));
emp.start().get(30, TimeUnit.SECONDS);

Subscription subscription = emp.subscribe(channel, replayFrom, event -> {
    Map<String, Object> header = (Map<String, Object>) event.get("ChangeEventHeader");
    String tx = (String) header.get("transactionKey");
    Integer seq = ((Number) header.get("sequenceNumber")).intValue();
    String recordId = ((List<String>) header.get("recordIds")).get(0);
    String dedup = recordId + ":" + tx + ":" + seq;

    if (!idempotencyRepo.claim(dedup)) return; // already seen

    // Process: read latest Account state if needed
    accountCache.invalidate(recordId);

    // Update checkpoint after success
    checkpointRepo.save(channel, (Long) event.get("replayId"));
}).get(30, TimeUnit.SECONDS);

Why EMP here?

  • JSON payloads are lightweight and easy to handle.

  • Perfect for CDC and quick admin-managed integrations.

  • EMP Connector (Java) makes implementation simple.


C) Apex Publisher (Optional) — Produce Platform Events for Pub/Sub Consumers

public with sharing class InvoicePublisher {
    public static void publishIssued(Id invoiceId, Decimal amount, String currency, String dedupKey) {
        InvoiceIssued__e e = new InvoiceIssued__e(
            InvoiceId__c = (String)invoiceId,
            Amount__c    = amount,
            Currency__c  = currency,
            DedupKey__c  = dedupKey // e.g., invoiceId:issued:v1
        );
        EventBus.publish(e);
    }
}

Pub/Sub API vs Streaming API/CometD/EMP


? Final Thoughts

Both Pub/Sub API and Streaming API deliver at-least-once with replay capabilities — so your design must ensure idempotency and checkpoint persistence.

  • Choose Pub/Sub API for:

    • High-throughput pipelines

    • Binary payloads (Protobuf)

    • Fine-grained flow control

    • Event publishing and consumption

  • Choose CometD/EMP for:

    • JSON-based, human-readable messages

    • Quick CDC subscribers or lightweight UIs

    • Low operational complexity

And remember:
Ordering is a per-key concept — use CDC’s transaction metadata or your own correlation IDs, and never depend on cross-key ordering.

This approach keeps your event-driven architecture resilient, idempotent, and production-grade.

  • October 21, 2025