Article
The Split-Brain Round Robin: Preventing Race Conditions in Lead Assignment
Most GTM systems work perfectly until they are actually successful. You launch a high-intent LinkedIn campaign or a webinar ends, and suddenly thirty leads hit your ingestion endpoint in the same second. Then the logs start bleeding.
Some leads aren't assigned. Others are assigned to the wrong person. A few reps complain they got three leads in a row while their teammates got nothing. This isn't "CRM flakiness." It is a mechanical failure of concurrency control known as a race condition—or what I call the Split-Brain Round Robin.
The Mechanics of the Race
When two leads arrive at the exact same millisecond, a naive routing script or a native CRM workflow enters a race. Both processes trigger simultaneously. Both look at the "Next Rep" pointer or a counter property and see the same value: Rep A.
- Process 1 reads: "Rep A is next."
- Process 2 reads: "Rep A is next."
- Process 1 assigns its lead to Rep A and updates the pointer to Rep B.
- Process 2 assigns its lead to Rep A (because it already read the stale state) and "updates" the pointer to Rep B again.
You now have a double-assigned rep and a teammate who was skipped entirely. In high-volume systems, this stale state evaluation happens constantly because native tools lack atomic locking—the ability to ensure that while one process is reading and updating a record, no other process can touch it.
Why Native CRM Locking Backfires
Salesforce and other enterprise CRMs use a pessimistic locking model to maintain data integrity. When a record is being updated, the system places a lock on it. If you have a high-volume ingestion pipeline, you will eventually hit the UNABLE_TO_LOCK_ROW error.
Salesforce is surprisingly patient; it will wait up to 10 seconds for a lock to be released. But if your lead ingestion triggers a chain of heavy Apex triggers, Flow cycles, or rollup summaries, the queue backs up.
The hidden culprit is often the parent Account. Updating a Contact frequently requires a lock on the parent Account to calculate rollups or sharing rules. If you are importing 50 Contacts for the same Account simultaneously, those 50 processes are all fighting for one lock. The system grinds to a halt not because the data is bad, but because the database cannot coordinate access fast enough.
In HubSpot, the failure mode is quieter. Workflows execute in parallel, and property updates are eventually consistent. There is no native mechanism to say "lock this property until this specific enrollment is finished." If two workflows try to increment the same counter at once, one update simply overwrites the other.
Solving for Atomicity: The Postgres Pattern
To solve this, you have to move the source of truth for your routing logic into a layer that supports atomic operations. If you are building ingestion middleware, Postgres provides the tools to handle this gracefully via SELECT ... FOR UPDATE.
When a worker process picks up a lead, it should query your Reps table using a locking clause. This forces other concurrent workers to wait until the first transaction is committed.
BEGIN;
-- Find the next rep in the rotation and lock the row
SELECT id, name
FROM round_robin_members
WHERE team_id = 'sales_ae'
ORDER BY last_assigned_at ASC
LIMIT 1
FOR UPDATE;
-- Update the timestamp so they move to the back of the line
UPDATE round_robin_members
SET last_assigned_at = NOW()
WHERE id = :selected_rep_id;
COMMIT;
For even better performance, you can use FOR UPDATE SKIP LOCKED. Instead of making workers wait in a queue for the same row, SKIP LOCKED tells the worker to ignore any rep records currently being updated by another process and move to the next available one. This prevents a single slow API call from bottlenecking the entire ingestion pipeline.
The Virtual Lock: Postgres Advisory Locks
Sometimes you don't want to lock a physical database row, but you still need to coordinate a specific task (like "Assigning Lead X"). This is where Advisory Locks come in. These are application-level locks that live in the database's memory.
// In your Node.js/Typescript worker
const lockAcquired = await db.query("SELECT pg_try_advisory_xact_lock(54321)");
if (lockAcquired) {
// Perform routing logic safely
} else {
// Another process is already handling this; skip or retry
}
Advisory locks are incredibly fast because they don't involve the overhead of the disk-based locking system. They allow you to define a lock on an arbitrary ID (like a Round Robin ID) to ensure only one worker is executing that specific logic at a time.
The Outbox Pattern
A common mistake is holding these database locks while waiting for a response from a third-party CRM API. If the Salesforce API takes 2 seconds to respond and you are holding a Postgres lock, you will quickly exhaust your database connection pool.
Use the Outbox Pattern:
- Inside a single, fast transaction, save the lead and assign the rep in your local database.
- Write a "Task" to an outbox table in that same transaction.
- A separate worker process polls the outbox and handles the actual CRM API calls asynchronously.
The lead gets a success response in 50ms, and the CRM reflects the change 500ms later. The locking remains local and brief.
When to Build vs. Buy
Building an atomic ingestion queue is a significant engineering lift. If your inbound volume is low—say, one lead every few minutes—the chance of sub-second collision is statistically negligible. Native CRM round-robins are perfectly fine here. Over-engineering a distributed lock for three leads a day is a waste of time.
However, if you are running large-scale events or high-spend performance marketing, these race conditions become a certainty. This is the point where specialized routing tools like LeanData or Chili Piper become commercially literate choices. They effectively act as a hardened outbox and queuing layer for your CRM, handling the physics of concurrency so you don't have to.
But if you do choose to build, remember: the CRM is a system of record, not a high-concurrency coordination engine. Treat it accordingly by managing your state and your locks in the ingestion layer.
— C.B.