One-way sync is a data pipeline. Two-way sync is a distributed system with two sources of truth, and it will find every gap in your design.
At TimeTackle we push calendar engagements into customers’ CRMs (a meeting with a prospect becomes an activity on the contact) and pull campaigns and contacts back so time can be reported against them. The complaint you dread with this kind of feature is some version of “why do I have three copies of this contact?”
This post is about the three failure modes behind those tickets and the design that fixed them.
The three ways two-way sync goes wrong
1. Concurrent creates. Two meetings with the same new person sync at the same moment. Both workers search the CRM for the contact, both find nothing, both create it. You now have two contacts.
2. Echo loops. We create a contact in the CRM. The CRM fires a contact.created webhook. We receive it, treat it as a change from the customer, and update our side, which triggers an outbound sync, which fires another webhook. In the worst version this recreates records that a user deleted.
3. Retries that duplicate. The CRM returns a timeout. Did the create succeed? Often, yes. If you retry blindly, you create it again.
Fix 1: claim before you create
The race in concurrent creates is a check-then-act problem, so the fix is to make check-then-act atomic. Before creating a contact, the worker takes a short-lived claim in Redis keyed on the thing that makes a contact unique for that account, usually a normalised email address.
const CLAIM_TTL_MS = 30_000;
export async function findOrCreateContact(accountId: string, email: string) {
const key = `crm:claim:${accountId}:${email.trim().toLowerCase()}`;
// 1. Fast path: we already know the CRM id.
const mapped = await mappings.findByEmail(accountId, email);
if (mapped) return mapped.externalId;
// 2. Only one worker may create this contact at a time.
const claimed = await redis.set(key, workerId, "PX", CLAIM_TTL_MS, "NX");
if (!claimed) {
// Someone else is creating it. Wait for their mapping instead of racing.
return waitForMapping(accountId, email, { timeoutMs: CLAIM_TTL_MS });
}
try {
// 3. Search again inside the claim: another process may have finished
// between our first lookup and taking the claim.
const existing = await crm.searchContactByEmail(email);
const externalId = existing?.id ?? (await crm.createContact({ email }));
await mappings.upsert({ accountId, email, externalId, origin: "tackle" });
return externalId;
} finally {
await releaseIfOwner(key, workerId); // compare-and-delete via Lua
}
}A few things that are easy to get wrong:
- Release only your own claim. If your work outlives the TTL, another worker may now hold the key. Deleting it blindly releases their claim. A small Lua script that deletes only if the value matches avoids this.
- The TTL is a safety net, not the protocol. It exists so a crashed worker doesn’t block a contact forever. Size it above your slowest normal CRM call.
- Re-check inside the claim. The first lookup is an optimisation; the second is the correctness check.
Fix 2: a dedupe gate for webhooks
Every inbound webhook passes through a gate before it reaches the queue. The gate answers two questions.
Have we processed this event already? CRM webhooks are at-least-once. We store the provider’s event id (or a hash of record id, type and timestamp when there isn’t one) with a unique constraint, and drop anything we’ve seen.
Did we cause this event? When we write to the CRM, we record what we wrote and when in the mapping table, marked with origin: "tackle". When a webhook arrives for that record within a short window and the changed fields match what we wrote, it’s our own echo, and the gate drops it. Where the CRM supports it, we also stamp writes with an integration user or a custom field, which makes this check exact rather than heuristic.
export async function shouldProcess(evt: CrmWebhookEvent): Promise<boolean> {
const inserted = await db.webhookEvents.insertIgnore({
provider: evt.provider,
eventId: evt.id,
receivedAt: new Date(),
});
if (!inserted) return false; // duplicate delivery
const lastWrite = await mappings.lastOutboundWrite(evt.recordId);
if (lastWrite && isEcho(lastWrite, evt)) return false; // our own change
return true;
}Fix 3: retries with the same identity
Retries are only safe if the retried operation has the same identity as the original. For creates, that means:
- Use the CRM’s idempotency key or external-id upsert when it has one. This is the best option by far.
- When it doesn’t, a timeout is treated as “unknown”, not “failed”. The retry starts with a search, not a create.
- Retry only transient failures (
429,5xx, timeouts) with exponential backoff and jitter. A400will fail the same way every time; send it to a dead-letter queue with the payload so someone can look at it.
The mapping table does the heavy lifting
Underneath all of this is one boring table: (account_id, provider, local_id, external_id, origin, last_synced_at, last_hash). Every write in either direction goes through it. Upserts are keyed on external_id. If you take one thing from this post, it’s that two-way sync without a durable identity mapping is guesswork, and every bug above gets harder to fix without it.
What changed
The claim, the gate and the retry rules together are what stopped the duplicate records and the recreate loops: concurrent creates serialise, echoes never reach the queue, and a retry can’t create a second copy. The system also became easier to reason about: each step has one job, and each one can be tested on its own.