Updated October 7, 2026
Change Data Capture is one of the best tools Salesforce gives you for keeping another system in step with your org. Something changes on an Account, Salesforce publishes an event, and anything subscribed hears about it within seconds. No polling, no nightly export, no custom trigger firing callouts on every save. (Change events are based on platform events, which we cover in Platform Events: The Hidden Infrastructure Behind Real-Time Business Operations.)
The documentation covers the setup well. What it does not cover are the problems you only hit once real records start moving. We recently built a two-way e-commerce integration between Salesforce and a Shopify store: orders flow into Salesforce, and customer changes in Salesforce flow back out to the store through CDC. Everything below comes from that build, and most of it only surfaced when we stopped testing against sample payloads and started testing against real events.
The Short Version of How CDC Works
You pick the objects you care about in Setup, under Change Data Capture. From then on, every create, update, delete, and undelete on those objects publishes a change event, such as AccountChangeEvent. Events are stored for 72 hours, so a subscriber that drops off can replay what it missed using the replay ID.
There are two ways to listen:
- Inside the org, with an Apex trigger on the change event object. A change event trigger executes asynchronously, outside the Apex transaction that published the change event, so it never slows down the user who made the change.
- Outside the org, through the Pub/Sub API. An external service opens a gRPC stream, authenticates, and receives events encoded in binary Apache Avro.
On our build we used both. The external middleware subscribed through the Pub/Sub API and pushed changes to the store. Inside the org, a small trigger on the same change events wrote a row to an integration log object for every event. That second subscriber did not do any syncing. Its job was visibility: an admin can open a list view or a dashboard in Salesforce and see what went out, when, and whether it succeeded, without logging into a server somewhere.
If you take one idea from this post, take that one. An integration nobody can see into is an integration nobody trusts.
Update Events Only Carry What Changed
This is the one that catches almost everyone.
When a record is created, the change event carries the full set of populated fields. When a record is updated, it carries values only for the fields that changed. The header tells you which fields those are in changedFields, and separately, in nulledFields, which ones were explicitly set to blank.
That sounds efficient, and it is, until your subscriber needs a field that did not change. In our case, the middleware needed the external customer ID stored on the Account to know which customer to update in the store. When a user changed only the loyalty tier, the event arrived with the tier and nothing else. No external ID. The subscriber did what it was told and skipped the update, because as far as it could tell, the record was not linked to anything.
It worked perfectly in the demo, because every test record was created fresh, and create events carry everything. It failed the first time someone edited a single field on an existing customer.
The fix has two parts:
- When the field you need is missing, go get it. The subscriber now re-queries the record through the REST API to fetch the current external ID before deciding to skip.
- Tell “not included” apart from “cleared”. If the header says the external ID was explicitly set to blank, that is a real change: someone unlinked the record, and the right move is to skip. If the field is simply absent, it did not change, and the re-query is the right move. Treating those two cases the same way gives you either missed updates or updates sent to a record that should no longer be touched.
When you design a CDC subscriber, list every field it needs to act on, not just the fields it reacts to. Anything on that list can be missing from an update event.
The Number Your Test Data Never Contains
Every change event header includes a commitNumber, a 64-bit long that holds the system change number of the committed transaction. On real events it is a very large number, larger than JavaScript can represent exactly with its standard number type, which tops out around nine quadrillion.
The popular Avro library for Node, avsc, notices this and refuses to decode the value, throwing a “potential precision loss” error. Our subscriber crashed on the first real event it received, even though every unit test had passed. The reason was simple: the test fixtures had small, hand-written commit numbers. Real ones do not.
The fix is to tell the decoder to read 64-bit values as BigInt:
const avro = require('avsc');
const longType = avro.types.LongType.__with({
fromBuffer: (buf) => buf.readBigInt64LE(),
toBuffer: (n) => {
const buf = Buffer.alloc(8);
buf.writeBigInt64LE(BigInt(n));
return buf;
},
fromJSON: (v) => BigInt(v),
toJSON: (n) => n.toString(),
isValid: (n) => typeof n === 'bigint',
compare: (a, b) => (a === b ? 0 : a < b ? -1 : 1),
});
const type = avro.Type.forSchema(schema, { registry: { long: longType } });
One follow-on to watch: JSON.stringify throws on a BigInt, so anywhere you log or forward the header, convert those values to strings first.
The broader lesson is about test data. Fixtures you write yourself encode your assumptions. Before you call a subscriber done, point it at a real org, make a real change, and watch it handle a real event end to end.
Covering CDC Triggers in Apex Tests
If you write an Apex trigger on a change event, it needs test coverage to deploy like any other trigger. The tempting shortcut is to call the handler class directly from a test with a hand-built event. That tests your logic, but it does not execute the trigger itself, so the trigger file shows zero coverage and the deployment fails.
The platform gives you what you need to test it properly. Turn on change events for the test, do real DML, then deliver the events:
@IsTest
static void accountUpdatePublishesAndIsLogged() {
Test.enableChangeDataCapture();
Account acct = new Account(Name = 'CDC Test Account');
insert acct;
Test.getEventBus().deliver();
acct.Loyalty_Tier__c = 'Gold';
update acct;
Test.getEventBus().deliver();
List<Integration_Log__c> logs = [
SELECT Direction__c, Status__c
FROM Integration_Log__c
];
Assert.areEqual(2, logs.size(), 'Expected one log row per change event');
}
Test.enableChangeDataCapture() makes the test publish change events for all supported objects, regardless of the entities selected in Setup. Test.getEventBus().deliver() delivers the test events to the trigger and fires it right there in the test. Then you assert on what the trigger was supposed to do, which is the part that actually proves it works.
Keep in mind that when you deploy with specific tests, each Apex component in the deployment needs 75% coverage on its own, even if your org’s overall coverage is higher. A CDC trigger that only a handler test touches will hold up the whole release.
Events Can Be Missed, So Plan for It
CDC is reliable, but it is not a guarantee on its own. Subscribers go down. Deploys restart services. A network hiccup drops a stream. Events are stored for 72 hours, and replay helps if your subscriber tracks the last event it processed, but after that window the event is gone.
So we did not rely on events alone. The build includes a reconciliation job in Salesforce that runs every six hours, asks the store for recent orders through its API, and ingests anything that is missing or out of date. The event path handles the normal case in seconds. The reconciliation path catches what fell through the cracks.
Two design choices make a backstop like this safe:
- Make ingestion idempotent. Key every record on the external system’s ID, so processing the same order twice updates one record instead of creating two.
- Isolate each record. Process each order inside its own savepoint, so one bad order rolls back cleanly without taking the whole batch with it, and without leaving a half-written opportunity behind.
The reconciliation job also earned its keep during testing. It found an order the event path had never tested: one containing a line item with no product record behind it. That failure showed up in the integration log exactly as designed, which is how we found it.
What We Would Tell You Before You Start
- Enable CDC only on the objects you need. Without the add-on license you can select only five entities, and every event delivered to an external subscriber (Pub/Sub API, CometD, and similar clients) counts against your daily delivery allocation. Apex triggers do not.
- List every field your subscriber needs, and plan for update events that do not include them.
- Decode 64-bit numbers as
BigIntif you subscribe from Node. - Cover CDC triggers with real DML and delivered events, not direct handler calls.
- Log every event, in both directions, somewhere an admin can see it.
- Build a reconciliation job, and make everything it touches idempotent.
- Test against a real org before you trust the results.
Change Data Capture is the right foundation for keeping Salesforce and another system in sync. It just needs the pieces around it: the subscriber that handles partial data, the log that shows what happened, and the backstop for the day something goes quiet. If you are planning an integration like this, we are happy to talk it through.
Sources
- Change Data Capture Developer Guide: Change Data Capture Allocations
- Change Data Capture Developer Guide: Change Event Triggers
- Change Data Capture Developer Guide: Change Event Message Structure
- Change Data Capture Developer Guide: Change Event Body Fields
- Change Data Capture Developer Guide: ChangeEventHeader Fields
- Change Data Capture Developer Guide: Test Change Event Triggers
- Metadata API Developer Guide: Running a Subset of Tests in a Deployment
- MDN: Number.MAX_SAFE_INTEGER
- MDN: BigInt
- avsc source: lib/types.js