Article
Atomic Ingestion Under Fire: Benchmarking Salesforce Composite vs. Composite Graph APIs
The dream of atomic ingestion is simple: when a product event or enriched lead hits your stack, you want to create the Account, the Contact, and the Opportunity simultaneously. If the Opportunity creation fails, you don't want a "ghost" Account sitting in Salesforce without a deal attached. You want a clean rollback.
For GTM operators, the Salesforce Composite Graph API is the promised land for this. While the standard Composite API limits you to 25 subrequests, the Graph API allows up to 500 subrequests across 15 separate "graphs" in a single call. It lets you chain records using a referenceId—creating an Account and immediately using its temporary ID to link a Contact and an Opportunity in one transactional block.
But under load, this atomicity becomes a liability. My recent benchmarks show that while Graph APIs solve for round-trip latency, they introduce a massive failure surface through UNABLE_TO_LOCK_ROW exceptions.
The Mechanics of the Parent Lock
Salesforce isn't a flat data store. When you insert a child record—like a Contact or a Contact Role—the platform often places a lock on the parent Account record to ensure data integrity, particularly if you have roll-up summary fields, apex triggers, or complex sharing rules.
If another process tries to modify that same Account (or add another child to it) while the first transaction is open, it has to wait. Salesforce gives that second process 10 seconds to acquire the lock. If it fails, you get the dreaded UNABLE_TO_LOCK_ROW error.
The Experiment: Standard vs. Graph
I simulated a high-concurrency burst typical of a PLG motion: 50 concurrent workers attempting to ingest record trees for the same enterprise Account.
1. Standard Composite (25 Subrequests)
With allOrNone set to true, I bundled small batches. The failure mode here was "leaky but local." Because each request was small, some workers managed to grab the lock and finish. Others timed out and rolled back their specific 25-record batch. Total throughput was throttled by the overhead of making many small HTTP calls, but the blast radius of any single failure was contained.
2. Composite Graph (500 Subrequests)
The Graph API allows for a much more complex payload. Here is a simplified look at the structure:
{
"graphs": [
{
"graphId": "graph1",
"compositeRequest": [
{
"method": "POST",
"url": "/services/data/v60.0/sobjects/Account",
"referenceId": "newAcc"
},
{
"method": "POST",
"url": "/services/data/v60.0/sobjects/Contact",
"referenceId": "newCon",
"body": { "AccountId": "@{newAcc.id}", "LastName": "Smith" }
}
]
}
]
}
In the benchmark, the throughput for unique, unrelated records was nearly 15x faster than standard REST. However, the moment shared parent records were introduced, the failure rate spiked to nearly 100%.
Because the Composite Graph is transactional, if subrequest #499 in a 500-record graph fails to acquire a lock on an Account that another worker is currently touching, the entire graph of 500 records rolls back.
The Transactional Paradox
This is the trade-off. We use Graph APIs to ensure data integrity, but the larger the graph, the higher the statistical probability that one record will hit a lock contention issue. In a high-concurrency environment, a single popular Account (e.g., a viral sign-up burst from a single domain) can effectively block your entire ingestion pipeline if those records are distributed across different concurrent Graph calls.
Architectural Remedy: Key-Based Partitioning
If you are building a GTM pipeline using Composite Graphs, you cannot treat your inbound queue as a simple "First-In, First-Out" (FIFO) buffer. You must introduce a middleware layer—using n8n, SQS, or a custom worker—that performs key-based partitioning.
- Group by Parent ID: Instead of batching records by arrival time, group your subrequests by the
AccountIDor a unique domain key. - Serialize the Hot Keys: Ensure that all records related to "Account A" are handled by the same worker or sent in the same Graph payload. By placing all children of a parent in a single graph, Salesforce handles the internal locking sequentially within that transaction, rather than forcing separate transactions to fight for the same row.
- Right-Size the Graph: While 500 is the limit, our tests suggest a sweet spot of 100–150 subrequests. This provides significant throughput gains while keeping the "blast radius" of a rollback manageable. Re-attempting a failed 500-record graph is expensive and often leads to the same lock timeout on the second try.
When to Step Away from Graphs
Composite Graphs are a specialized tool for synchronous, multi-object relationships. They are not a catch-all for bulk data.
- For Mass Backfills: If you’re moving 50,000 records, use Bulk API 2.0. It is asynchronous and has native logic for lock serialization that handles these contentions much more gracefully than the REST-based Graph API.
- For Low Volume: If you're only processing 500 records an hour, the complexity of building Graph JSON and managing
referenceIdlogic is rarely worth it. Standard REST or simple Composite calls are easier to debug and observe in your logs.
Summary
Atomic ingestion is a requirement for clean CRM data, but it requires a builder’s understanding of the underlying database. If you use Composite Graphs without partitioning your data by parent ID, you aren't building a faster pipeline—you're building a more efficient way to trigger rollbacks. Control your locks at the queue level, or the platform will control them for you by killing your transactions.
— C.B.