← Back to Field Notes

When to Use Platform Events vs Change Data Capture

·9 min read

The first time Change Data Capture bit me, it was a Tuesday night data load and a downstream reporting system that had gone quiet. We'd switched CDC on for the Account object months earlier to keep a warehouse in sync, and it had run without complaint the whole time. Then someone ran a bulk update across 180,000 Accounts to fix a phone-number formatting issue, and CDC did exactly what it's designed to do — it published a change event for every single record. The subscriber on the other end fell over somewhere around the 62,000 mark, and by the time anyone noticed, the sync was hours behind and nobody could tell which records had made it across.

That night taught me the thing this whole article is about. Platform Events and Change Data Capture look almost identical when you're setting them up, and people reach for whichever one they saw first. Choosing the wrong one isn't a style preference — it's the difference between an integration that ages well and one you're babysitting at 2am.

The Distinction That Actually Matters

Both features publish onto the same enterprise message bus. Both sit on that bus and stay replayable for up to three days. Both can be consumed by an Apex trigger, a record-triggered Flow, a Lightning Web Component through the empApi module, or an external system subscribed over CometD. On paper they look like two flavours of the same feature. They aren't, and the difference is about who authors the event.

Change Data Capture publishes events that Salesforce authors for you. Every insert, update, delete or undelete on a tracked object produces a change event describing what moved, field by field, plus a header telling you the change type and the record IDs involved. You don't write any publish logic. You opt an object in, and the platform emits the events.

Platform Events are events you author. You define the schema, you decide the exact moment one fires, and you decide what goes in the payload. An Order_Confirmed__e event means an order was confirmed — a business fact, stated in your language. It does not mean a field on a record happened to change. That gap between "a record changed" and "something meaningful happened" is the entire decision.

Platform Events Are for Business Intent

I reach for Platform Events when I have a business fact to broadcast and I don't want the publisher and the subscriber to know anything about each other. The classic shape is an order confirmation, a payment received, a case escalated — a moment in a process that other systems care about, where the payload is a deliberate contract rather than a dump of record fields.

Publishing one is a single call. I usually do it from the service layer, well away from the trigger that started the transaction:

// Broadcast a business fact once the order is genuinely confirmed
Order_Confirmed__e evt = new Order_Confirmed__e(
    Order_Id__c     = order.Id,
    Order_Number__c = order.OrderNumber,
    Confirmed_At__c = System.now(),
    Total_Amount__c = order.TotalAmount
);

Database.SaveResult sr = EventBus.publish(evt);
if (!sr.isSuccess()) {
    for (Database.Error err : sr.getErrors()) {
        System.debug(LoggingLevel.ERROR, 'Event publish failed: ' + err.getMessage());
    }
}

The value here is the decoupling. The billing system, the notification service and the warehouse can all subscribe to Order_Confirmed__e without the order code knowing they exist. Add a fourth subscriber next year and you change nothing on the publish side. You control the payload, so you're not leaking your internal field schema to every downstream consumer — which matters the day someone renames a field and you'd rather that not become a cross-system incident.

Change Data Capture Is for Replication, Not Logic

CDC earns its place when the job is "keep a copy of this data somewhere else and don't miss anything." Warehouse sync, a search index, a mirror in a legacy system. You get the changed fields for free, you get creates and deletes and undeletes without writing a line of publish code, and you get a transaction key so a subscriber can reassemble a unit of work.

If you do consume CDC inside the org with an Apex trigger, the header is where the useful information lives:

trigger AccountChangeEventTrigger on AccountChangeEvent (after insert) {
    List<Id> toSync = new List<Id>();

    for (AccountChangeEvent evt : Trigger.new) {
        EventBus.ChangeEventHeader header = evt.ChangeEventHeader;

        // Only react to updates that actually touched billing details
        if (header.getChangeType() == 'UPDATE'
            && header.getChangedFields().contains('BillingCountry')) {
            toSync.addAll(header.getRecordIds());
        }
    }

    if (!toSync.isEmpty()) {
        System.enqueueJob(new AccountSyncJob(toSync));
    }
}

Here's the opinion, and it comes from cleaning up other people's messes: do not build business logic on CDC triggers. Treat them as a thin dispatch layer and nothing more. A CDC trigger is an event subscriber, so it deserves the same one-trigger-per-object discipline I wrote about in A Trigger Framework That Actually Scales, but it also carries risks a normal trigger doesn't. It fires on bulk operations you didn't initiate, it can be replayed, and delivery is at-least-once — meaning your subscriber can and will see the same change twice. The moment you put "calculate the commission" or "send the invoice" inside a CDC trigger, you've built something that double-charges a customer the first time an event is redelivered. Notice the code above does nothing inline. It enqueues. Anything heavier than a field comparison belongs in a Queueable, which is a whole discussion of its own in Async Apex: Queueable vs Batch vs Future.

The Publish-After-Commit Trap

This one only applies to Platform Events, and it's caught more than one team I've worked with. When you define a Platform Event, you choose its publish behaviour: "Publish After Commit" or "Publish Immediately." The names sound like a performance tuning knob. They aren't.

Publish Immediately puts the event on the bus the instant EventBus.publish runs — before the transaction that raised it commits. If that transaction then rolls back, the record change never happened, but the event is already gone. Subscribers act on an order confirmation for an order that, as far as the database is concerned, was never confirmed. I've watched a downstream system create a real invoice for a rolled-back sale because of exactly this. For anything tied to a database change, use Publish After Commit so the event only leaves the building if the transaction actually succeeded.

CDC, for what it's worth, sidesteps this entirely. It only ever emits on commit, because it's reacting to committed data. That's one of the quiet reasons CDC is the safer default for replication.

Volume Is Where Both of Them Bite

Both features draw from the same shared daily event delivery allocation, and both can quietly blow through it. This is the failure mode that took down my warehouse sync, and it's almost always triggered by a legitimate operation: a bulk update, a nightly integration, or a data migration that touches a few hundred thousand records in an afternoon.

CDC is especially dangerous here because it's invisible. Nobody running a bulk cleanup thinks about the change events they're about to generate — one update statement across 180,000 records is 180,000 events on the bus, and every subscriber has to keep up or fall behind. Before I turn CDC on for an object now, I ask what the largest realistic bulk operation on it looks like, and whether the slowest subscriber can drink from that firehose. If the answer is no, either the subscriber gets an idempotent queue in front of it or CDC is the wrong tool. During large migrations I now switch CDC subscriptions off entirely, load the data, and reconcile with a batch job afterwards rather than letting millions of change events flood the bus for a one-time load that nobody downstream cares about.

Platform Events give you more control, because you decide when they fire, but the allocation is still real. Publishing an event per record inside a bulk trigger is the same mistake wearing a different hat.

When Neither Is the Answer

The clean answer is to pick the right event feature. In practice, sometimes the right answer is no event at all.

If you need a response — a real-time price, an address validation, an approval decision — an event bus is the wrong shape entirely. Events are fire-and-forget; there's no reply channel. That's a synchronous callout, and pretending otherwise just adds latency and complexity. If the requirement is complex routing, transformation or orchestration across five systems, that's middleware territory, and a proper integration platform will serve you better than a web of point-to-point subscribers wired directly to the org. And if the data genuinely only needs to be current once a day, a scheduled batch is boring, cheap, and impossible to get subtly wrong in the way an event stream can.

The other honest limitation is delivery semantics. Both features are at-least-once, never exactly-once. If you cannot make your subscriber idempotent — if processing the same event twice does real damage you can't undo — then you have a design problem that no amount of Platform Events or CDC will paper over. Solve the idempotency first. If you want a second pair of eyes on an event architecture before it goes near production, that's the kind of review I do.

I've stopped thinking of these as competing features and started thinking of them as two different sentences. Platform Events tell the rest of the world what happened. Change Data Capture tells it what the data now looks like. Get that sentence right and most of the architecture falls out of it on its own — get it wrong, and you find out on a Tuesday night with a queue backing up and no idea which records made it across.

Liked this?

New essays in your inbox when I publish. No cadence, no spam.