Skip to content

Build a two-way sync that does not loop.

One-way sync is solved. Two-way sync is a different problem, and the first failure appears within minutes: two systems updating each other forever.

oniru.ai8 min read
Symmetrical railway junction tracks overhead representing bidirectional routing.

One-way sync is a solved problem. Read from A, write to B, handle the failures. Two-way sync looks like the same job done twice and it is not. It is a different problem with its own failure modes, and the first one appears within minutes of switching it on.

A client wants contacts kept in step between their CRM and their support tool. Somebody edits a phone number in the CRM. The sync writes it to the support tool. The support tool fires a change event. The sync writes it back to the CRM. The CRM fires a change event. Two systems now update each other several times a second until a rate limit stops them, and the client’s audit log fills with thousands of identical changes.

This is how to build one that does not do that.

The loop is the default behaviour.

Nothing in either system is misbehaving. Each is doing exactly what it was asked: reporting that a record changed. The sync cannot tell the difference between a change a person made and a change the sync itself made a moment ago.

Everything else in this post follows from that single problem. The sync needs a way to recognise its own writes.

Keep an identity map, not a convention.

Before any of that, the sync needs to know which record in A corresponds to which record in B. Matching on email at every run is fragile, because emails change and the match then creates a duplicate rather than an update.

Hold an explicit mapping, owned by the sync rather than by either system.

pair_id | a_id     | b_id      | a_version | b_version | last_synced
--------+----------+-----------+-----------+-----------+-------------
p_0001  | crm_8821 | help_4410 | 17        | 9         | 2026-09-06T…
p_0002  | crm_8822 | help_4411 | 3         | 3         | 2026-09-06T…

This table is the sync. It answers what pairs with what, what each side looked like when they were last agreed, and when that was. Store it somewhere you control, not in a custom field inside one of the systems, because the moment it lives in one system you cannot recover from that system being restored from a backup.

Where the systems expose a version, revision or updated_at you can compare, store it. Where they do not, store a hash of the fields you sync, which serves the same purpose.

Suppress the echo.

Three mechanisms, in increasing order of reliability. Use as many as the systems allow.

Compare first

Before writing, compare the incoming value with what is already there. If they are identical, do not write. This one change eliminates most loops on its own, because the echo is by definition a write of a value the target already holds.

It is not sufficient alone. Systems normalise values on write, so a phone number sent as +34 600 000 000 may come back as +34600000000, which no longer compares equal and loops forever. Normalise both sides before comparing, using the same function.

Record the version

After writing to B, store the version B reports back in the mapping row. When B’s change event arrives, compare its version to the stored one. If they match, this is your own write returning and it should be dropped.

on change from B:
    row = map.find(b_id)
    if row.b_version == event.version:
        drop                       # our own write, echoing back
    if hash(fields) == row.b_hash:
        drop                       # no material change
    proceed

Mark the actor

The strongest option where it exists. Write through a dedicated integration user rather than a person’s account, and check the actor on incoming events. A change made by the sync user is a change the sync made, and no comparison is needed.

Not every system reports who made a change, which is why the first two mechanisms matter. When it does, this is the one to rely on.

Decide who owns each field.

The instinct is to sync every field in both directions. This produces the most conflicts and the least value.

Go field by field and assign a direction. Most fields have an obvious owner once someone asks the question.

Field Direction Why
Email, phone Both Updated in whichever system the person is talking to
Lifecycle stage CRM to support Sales owns it, support reads it
Open ticket count Support to CRM Support owns it, sales reads it
Internal notes Neither Different audiences, syncing them helps nobody

A field synced in one direction only cannot conflict. Every field you move out of the two-way set removes a category of problem permanently, and clients are usually surprised at how few genuinely need both directions.

Resolve conflicts on a rule you can explain.

A conflict is when both sides changed since the last agreed state. The mapping table tells you this: neither stored version matches what each system now reports.

Three workable rules.

Most recent wins is simple and usually acceptable. Compare modification timestamps and take the later one. Be careful that both timestamps are in the same timezone and that you trust both clocks.

One side wins by field. The CRM is authoritative for commercial fields, the support tool for support fields, and a conflict is settled by whichever owns that field. This is more work to specify and much easier to explain to the client.

Hold for a person where the field matters enough. A conflict on a phone number can be resolved automatically. A conflict on a billing address might deserve someone looking.

Whichever you choose, log every conflict with both values and the outcome. When the client asks why a value changed back, that log is the answer, and without it there is no answer.

Reconcile on a schedule.

Event-driven sync misses things. Webhooks fail, are not delivered, or are not emitted at all for changes made by an import or by an administrator in bulk.

Run a periodic full comparison, nightly for most systems. Walk the mapping table, fetch both sides, and report every pair whose fields disagree.

Start by having the reconciliation only report, not correct. Run it for a fortnight and read the output. Divergence usually reveals a real gap in the event handling, and fixing that gap is better than papering over it with an automatic correction that hides the problem. Once the report is consistently empty, you can let it correct.

Reconciliation is also the only way to detect records created directly in one system while the sync was down.

Deletion is the one to be careful with.

Propagating deletions across a two-way sync is how a client loses data. A bulk cleanup in one system becomes a bulk deletion in the other, and there is no obvious undo.

The safe default is not to propagate deletions at all. Instead, mark the pair as orphaned in the mapping table and report it. Somebody decides.

Where the client genuinely wants deletions to propagate, make it one-way from the system that owns the record, require the record to have been absent for a defined period rather than acting immediately, and cap how many deletions the sync will perform in one run before it stops and asks. A sync that suddenly wants to delete four hundred records is reporting a problem, not doing its job.

The first sync is the hardest one.

The initial backfill matches existing records that were created independently and now have to be paired. This is a deduplication problem, and it is where most of the project’s effort goes.

Run it as a dry run first. Produce a report of proposed pairs, proposed creations and everything ambiguous, and have the client review the ambiguous set before anything is written. Match on the strongest key available, prefer to leave an uncertain pair unmatched rather than pairing wrongly, and remember that an incorrect pair is far more expensive to unpick later than an unmatched record is to match now.

Only after the backfill is agreed should the live sync be switched on.

Test the loop deliberately.

The specific cases worth running before this goes near production:

  1. Change a field in A and confirm exactly one write lands in B, and that nothing comes back.
  2. Change the same field in both systems within a few seconds and confirm the conflict rule fires and is logged.
  3. Change a value to something a system will normalise, such as a phone number with spaces, and confirm it settles rather than ping-ponging.
  4. Stop the sync, change records on both sides, restart it, and confirm reconciliation catches up correctly.
  5. Delete a record in one system and confirm the sync reports rather than deletes.
  6. Run a bulk import into one system and confirm rate limits are respected and nothing is dropped.

Case three catches more real loops than any other, and it is the one most often skipped.

What to hand over.

The field direction table, because it is the specification. The conflict rule in plain language. Where the mapping table lives and how to read it. How to run reconciliation manually and how to interpret its output. And a clear statement of what the sync deliberately does not do, starting with deletions.

If a client wants two systems kept in step and you would rather not own the loop suppression and the conflict rules, send us the two systems and which fields matter. We will tell you which ones genuinely need both directions. Send us the details.

Have a client asking for something you can’t easily build?

Send us the requirement. We’ll tell you how we’d approach it, what it would require and whether it makes sense to build.

No pitch deck required. A rough description is enough.

Or write to [email protected]