← Back to Blog

Two-Way Sync Loops: When Two Systems Keep Updating Each Other Forever

The first sign is rarely an error. It is a number. The execution list for a sync workflow shows thousands of runs since this morning, on a CRM that a few dozen people use. Or a Notion page whose history is nothing but your integration, edit after edit, seconds apart, each one setting a field to the value it already had. Or a rate limit error from an API you are barely using, except that you are, because you are writing the same record to it on a loop.

This is the echo loop, the infinite loop most two-way syncs ship with. System A changes, so the sync copies the change to B. B reports that it changed, which is true, so the sync copies it back to A. A reports that it changed. Every step is correct, and nothing ever stops.

This post covers why two-way syncs feed themselves, the slower loops that do not look like loops at all, and the defences, from the two that do most of the work to the one that looks like a fix and is not. The running example is Attio and Notion in n8n. The first-hand parts come from two production builds: the SmoothOps sync, which I kept one-way on purpose, and the ScaleXP ledger sync between Teamwork and Xero, which is two-way. None of it is specific to those tools.

How a two-way sync feeds itself

A one-way sync that never writes back to its source cannot loop. Data leaves A, lands in B, and B has no route back. The moment you add the return path you have built a circuit, and anything that enters it keeps going round until something takes it out.

The echo · One human edit, and a sync that answers itself
  1. 1A person edits a job title in Attio
  2. 2The sync copies it to Notion
  3. 3Notion reports a change, so the sync copies it back
  4. 4Attio reports a change. Again.
Attio to Notion
Notion to Attio
writes so far
01 · the real one2 · an echo3, and still going
Nobody has touched the record since the first edit. Every write after the first copy is the sync replying to itself.
Neither workflow is wrong. Each sees a record change and copies it across, which is its whole job. Nothing tells either one that the change was its own.

Here is the shape almost everyone starts with: two workflows, each one reasonable on its own.

two correct workflows, one loop
// Two workflows. Each one is correct. Together they never stop.
// attio and notion stand in for HTTP Request nodes.

// Workflow A · trigger: Attio record.updated
const person = await attio.getPerson(recordId);
await notion.updatePage(pageId, { jobTitle: person.jobTitle });

// Workflow B · trigger: Notion page.properties_updated
const page = await notion.getPage(pageId);
await attio.updatePerson(recordId, { jobTitle: page.jobTitle });

Read either workflow alone and there is nothing to fix. The problem is what the second one receives. A webhook or a poll tells you that a record changed. It does not tell you why, and on its own it does not tell you that the change was the write your other workflow made two seconds ago. From the receiving side, the sync's own write and a salesperson's edit are the same event.

So the loop is not a bug in either workflow. It is a missing fact: neither side can tell its own echo from real news. Every defence below is a different way of putting that fact back.

It is also easy to trigger more often than you would guess. Attio's record.updated event names a single attribute, so a sync that writes four fields onto an Attio record can hand itself four events, and four laps, from one write.

Not every loop is fast

The loop in that figure is the friendly version. It runs at webhook speed, it burns through an execution quota by lunchtime, and somebody notices. Two slower shapes are worse, because they can run for months without anyone looking.

The loop that runs on a schedule

The ScaleXP sync between Teamwork and Xero runs twice a day on a schedule, not on webhooks. A scheduled sync cannot spin thousands of times an hour, so it feels immune. It is not. If each run copies A to B and then B to A, and a write on either side counts as a change, the same record crosses back and forth on every cycle, forever.

Nothing looks wrong. The runs are quiet and the numbers are small. The only symptom is that every record's modified date is always today, which quietly destroys the one signal you would use to find records that really changed. One rule stops it, and it comes up below.

The loop that is really a disagreement

The second slow shape is two systems holding the same value in different forms. Your sync writes a phone number as +44 7700 900123 and your mapping reads it back as 447700900123. A date written as local time comes back in UTC. A multi select comes back in a different order. A title comes back as a rich text array instead of a string. Each run compares, sees a difference, writes, and the write triggers the next run, which sees the same difference.

The values never converge, because the difference was never in the data. It was in the comparison. I hit a version of this on SmoothOps with all day meetings: converting an all day date to local time moved it back a day, so the date no longer matched the one it was compared with. There it broke matching rather than looping, but it is the same failure. The sync and the system disagree about what the value is, and they will keep disagreeing forever.

That is why the equality check further down has to normalise before it compares. A comparison of raw API shapes is not a check. It is a coin that always lands on different.

Decide who owns each field

The first defence is not code. It is a table.

Most two-way syncs are not really two-way. They are two one-way syncs pointed at the same record, and nobody decided which system is the truth for which field. That undecided question is where both loops and conflicts come from. If a salesperson edits a job title in Attio and a delivery lead edits the same job title in Notion ten minutes later, a sync with no owner has two choices, and both are bad: pick one at random, or bounce between them.

So write it down. Every synced field has exactly one owning system. That system's value wins, changes flow out of it, and a change to that field on the other side is not an update to copy. It is a conflict to surface.

the ownership map, enforced
// One writer per field. The map is the design, the code only enforces it.
const OWNER = {
  name:        'attio',
  email:       'attio',
  job_title:   'attio',
  company:     'attio',
  stage:       'notion',
  next_step:   'notion',
  notion_link: 'sync',   // written by the sync, edited by nobody
};

const source  = 'notion';                             // where this run's change came from
const changed = $input.first().json.changedFields;    // e.g. ['stage', 'job_title']

// Only the owner's edits are updates to copy across.
const allowed = changed.filter(f => OWNER[f] === source);

// A Notion edit to an Attio field is a conflict, not an update.
const conflicts = changed.filter(f => OWNER[f] && OWNER[f] !== source && OWNER[f] !== 'sync');

return [{ json: { allowed, conflicts } }];

On SmoothOps the answer was lopsided on purpose. Attio is the source of truth, and everything flows one way: Granola to Attio to Notion. Attio wins so completely that an empty field in Attio clears the Notion side, and meeting pages have exactly one workflow allowed to write them. There is no data sync from Notion back to Attio at all.

When someone in Notion needs the Attio to Notion sync to run again for a record, a button on the row nudges it. It changes one field on the Attio record, waits fifteen seconds, and puts the original value back. Those two changes fire Attio's own events, the Attio to Notion sync runs, and the field ends exactly where it started. The net change to Attio's data is zero.

That button is on demand only, deliberately. Wiring it to fire on every row edit would loop, because the people sync writes that same Notion database constantly. Ownership turned a two-way problem into a one-way sync plus a refresh a person triggers on purpose. A one-way sync can only loop through what it writes back to its source, which on SmoothOps is a single link field, covered below.

When a field really does need editing on both sides, ownership still earns its place, because it settles conflicts in advance. The owner wins. The other side's edit is flagged to a person instead of silently overwritten, and it never becomes a write that starts another lap.

Write nothing when nothing changed

If I could keep only one rule in a two-way sync, it would be this one. It is the quietest line in the ScaleXP build: if both sides already hold the same value, nothing is written.

The backstop · Compare first, write only a difference
  1. 1A person edits a job title in Attio
  2. 2The sync copies it to Notion
  3. 3Notion reports a change, the sync reads both sides
  4. 4Already equal. Nothing is written.
Attio to Notion
Notion to Attio
waitingcomparing Head of Ops with Head of Opsequal, so no write and no event
writes so far
01 · the real one1 · converged
The echo still arrives. It finds nothing to do, and a loop with nothing to write stops by itself.
This is the rule that ends a loop even when every other defence has missed it. It costs one read before every write.

Look at what it does to the loop. The first lap is real: a person changed something and the sync copies it. The second lap is the echo, and it arrives exactly as before. But before writing, the sync reads the other side and compares, and the values are already equal, because the first lap put them there. So it writes nothing. No write means no event, and no event means no third lap. The loop never has to be detected. It runs out of fuel.

That is why this rule sits under every other one. Echo markers can be missed, actor filters can be fooled, triggers can be too broad. A sync that only ever writes a real difference cannot loop for more than one lap, whatever else goes wrong.

The part people get wrong is the comparison.

compare normalised values, write only the difference
// Both sides are already flattened to plain values by a mapping step.
// Normalise them with the same function, then compare.
function norm(field, v) {
  if (v === null || v === undefined) return '';
  if (Array.isArray(v)) return v.map(x => norm(field, x)).sort().join('|'); // multi selects are sets
  let s = String(v).trim().replace(/\s+/g, ' ');
  if (field === 'email') s = s.toLowerCase();
  if (field === 'phone') s = s.replace(/[^\d+]/g, '');
  return s;
}

const current = $('Read Destination').first().json;  // re-read, never the payload
const next    = $input.first().json.proposed;         // what the owner says now

const diff = {};
for (const field of Object.keys(next)) {
  if (norm(field, current[field]) !== norm(field, next[field])) diff[field] = next[field];
}

// Nothing differs: return no items, so the write node never runs.
if (Object.keys(diff).length === 0) return [];
return [{ json: { diff } }];

Three details carry the weight. It re-reads the destination instead of trusting the payload, because a webhook is a notification rather than a snapshot, and Notion's own documentation warns that events may not show the current state of the data. It normalises both sides with the same function, which stops the flapping from the last section once that function has a rule for every form a field arrives in. Dates need a rule of their own. And when nothing differs it returns no items at all, so the write node downstream never runs. Not a write of the same value. No write. That works in the Code node's default mode, Run Once for All Items, which is how every sample in this post is written.

That last point matters, because "it is idempotent, so it does not matter" is a trap. Setting a field to the value it already holds leaves the data unchanged, but you cannot count on the destination to notice that. Many systems still treat it as an edit, bump the modified time, and fire the event, and an event is all the other side needs to start a lap.

There is a bonus. A converged sync is quiet. On the SmoothOps meeting queue a run where nothing changed writes nothing at all, and on ScaleXP a clean run sends no email. That turns the write count into a health signal. A sync still writing hundreds of records a day during a slow week is either looping or flapping, and you can see it in the execution list before anyone reports a thing.

Recognise your own echo

The comparison lets the echo arrive and then refuses to write it. Recognising the echo stops it at the door, before the sync even reads the other side.

Markers: the field only the sync writes

The simplest marker is a field the sync owns outright. On SmoothOps, the people sync writes the Notion page's link back onto the Attio record. That write is itself an Attio change, so Attio fires record.updated again. The workflow recognises the link it wrote and stops there, which is what prevents an endless loop.

The marker · Recognise the field only the sync writes
  1. 1Someone edits a contact in Attio
  2. 2The sync writes the Notion page
  3. 3The sync writes the page link back to Attio
  4. 4Attio fires on the link. The sync stops.
Attio to Notion
link to Attio
attribute changed
job titlenotion linknotion link · ours, stop here
The event names the attribute that changed. When it is the one field no person ever edits, there is nothing to copy.
This is how the SmoothOps people sync avoids its loop: it recognises the link it wrote and stops there.

It works because each Attio record.updated event carries the attribute_id of the one attribute that changed. If that is the field no person ever edits, the event is the sync's own footprint and there is nothing to copy. Check it as early in the workflow as you can. It costs nothing.

Fingerprints: the state you last wrote

A marker works for one field. A fingerprint works for the whole record. When the sync writes, it also stores a hash of the synced fields exactly as it wrote them. When a change arrives, it hashes the record's current synced fields the same way. If the two hashes match, the record is exactly as the sync left it, and whatever fired the event, it was not a change to anything the sync cares about.

a fingerprint of the last synced state
// Paste the same norm() from the comparison node above this line.
// A Crypto node (SHA256, Property Name: fingerprint) then hashes canonical.
const FIELDS = ['name', 'email', 'job_title', 'company'];
const record = $input.first().json;

const canonical = FIELDS
  .map(f => `${f}=${norm(f, record[f])}`)
  .join('\n');

return [{ json: { canonical } }];

// Is This Our Echo?   IF node, string equals
{{ $('Hash State').first().json.fingerprint }}
{{ $('Read Record').first().json.lastSyncedFingerprint }}

Two rules make fingerprints trustworthy. Build the string from a fixed list of fields in a fixed order, normalised by the same function as the comparison, or two identical records will hash differently. And store the fingerprint somewhere cheap to read: a hidden property on the record, or a row in an n8n Data Table keyed by record id. Storing it on the record is itself a write, so treat that property as a field the sync owns, exactly like the link above.

It answers a different question from the comparison. Not "does the destination already match", but "has anything changed since I last touched this record". That lets the sync skip a record without reading the other system at all.

Filter by who made the change, carefully

Both platforms will tell you who made a change, and it is the defence people reach for first. It also has the sharpest edge.

Attio puts an actor on every webhook event, with a type of workspace-member, api-token, system or app, and an id. For an API token the id identifies the token, so an event carrying your sync's token is your sync's write. Notion's webhook events carry an authors array, each entry typed person, bot or agent. If you poll instead, which is what the n8n Notion Trigger does, the page object has a single last_edited_by.

notion: drop only events that are entirely ours
// Drop the event only when every author is our own bot.
// The bot's user id comes from GET /v1/users/me with the sync's token.
const OUR_BOT = 'your-bot-user-id';

const authors = $input.first().json.body.authors ?? [];
const onlyUs  = authors.length > 0
  && authors.every(a => a.type === 'bot' && a.id === OUR_BOT);

if (onlyUs) return [];
return [{ json: $input.first().json.body }];

Note the every. Notion aggregates frequent events, property updates included, over a short window, so one event can carry more than one author. A filter that drops the event when any author is your bot also drops the person who edited the page just before your write landed. Drop an event only when every author is you.

attio: the same check, keyed on the token
// Attio names the token that made the change. Ours means it is our echo.
const OUR_TOKEN = 'your-api-token-id';

// One event per delivery today, but events is an array, so name the first.
const event = $input.first().json.body.events[0];
if (event.actor?.type === 'api-token' && event.actor.id === OUR_TOKEN) return [];

return [{ json: event }];
The trap · Filtering on who edited last
  1. 1A person changes a phone number in Notion
  2. 2The sync writes a status to the same page
  3. 3The poll runs: last edited by the bot
  4. 4Page skipped. The old phone number stays.
notion page
phonestatusname
last edited by
a personthe sync's bot
the filter
bot edit, skip this page
Attio
still has the old phone number
A page has one last editor. The filter sees who touched it last, not everything that changed since the last poll.
Nothing fails and nothing is logged. The skip looks exactly like a page with nothing to sync.

Polling makes the trap worse, because a page has exactly one last editor. If a person changes a phone number and your sync writes a status to the same page before the next poll, the poll sees your bot as the last editor, the filter skips the page, and the phone number does not sync until someone happens to edit that page again, which may be never.

Attio has a better answer here, and it is worth knowing. Every attribute value it stores records the actor that wrote it. When you re-read the record, you can check who wrote each field rather than who touched the record last. Authorship per field is the version of this filter that holds up.

Actor filtering breaks in two more ways. It stops working the day a second automation shares your integration's token, because that automation's edits now look like yours and get dropped. And the list of actors keeps growing. Notion now types agents separately from bots, and an AI agent working in the workspace makes real edits that need syncing. A filter written as "ignore anything that is not a person" will ignore the agent too. There is more on what agents change in the post on Notion external agents.

Use actor filtering to save work, never as the only thing between you and a loop. Keep the comparison behind it.

Listen to less

An event you never receive cannot start a lap. Narrowing what triggers the sync is one of the cheapest defences there is, and it is usually a single filter on the subscription.

Attio webhook subscriptions accept a filter, set through the API, that Attio applies before anything is sent. You can match on id.attribute_id, so a subscription only fires for the attributes you actually sync, and on actor.id, so it never fires for your own token at all.

attio subscription filter
{
  "$and": [
    { "field": "id.attribute_id", "operator": "equals",     "value": "job-title-attribute-id" },
    { "field": "actor.id",        "operator": "not_equals", "value": "your-api-token-id" }
  ]
}

On the Notion side, page.properties_updated events carry updated_properties, the ids of the properties that changed. A Switch or IF node on that list can end the run right after the trigger when the only properties that changed are the ones the sync writes.

On SmoothOps, listening to less did real work. The company sync's webhook fires only on Name, Domains or Description, so editing any other field on an Attio company never reaches Notion. And the meeting pages workflow has three possible entry points, one of which, the note created event, is switched off on purpose. It could not tell which queue row a note came from, and it created a stray page every time a note was edited again.

Removing a trigger is a legitimate fix. Some events are not information.

Why a timestamp window is not a fix

Sooner or later someone suggests the obvious rule: after the sync writes a record, ignore any change to that record for the next ten seconds. It looks like echo suppression. It is really a guess about timing, and timing is the one thing a sync does not control.

The window · Ignore any change within 10 seconds of my write
  1. 1The sync writes at 12:00:00
  2. 2A person edits at 12:00:06
  3. 3Inside the window, so it is dropped
  4. 4The sync's own echo lands at 12:00:14
10s window
the person
not yetedit arrives at +6sinside the window, dropped
the echo
in flightoutside the window, synced back
The window gets both jobs backwards. It throws away the real edit and lets the echo through.
A window is a guess about delivery time, and delivery time is the one thing a sync does not control.

It fails in both directions at once. Delivery is not instant. Notion says events should arrive within five minutes and most within one, and aggregated events wait on purpose. Attio retries a failed delivery with backoff over about three days. So your own echo can easily land after the window has closed, and it is synced back as if it were news. Meanwhile a person who edits the record inside the window, perhaps reacting to the change they just saw, has their edit silently thrown away.

Then there is resolution. A Notion page's last edited time is only accurate to the minute. n8n's own Notion Trigger works around this by rounding its checkpoint down to the minute and remembering which pages it has already seen within it. A ten second window measured against a timestamp that cannot express ten seconds is not a window. And any window that compares your clock with theirs inherits the drift between the two.

Last write wins has the same weakness. Whether it goes by arrival order or by timestamp, it inherits every delay and every rounding error above, so the winner is the write that looked last, not the one made last. Settle conflicts with ownership, and use timestamps for ordering events, which is what they are for.

Put the layers together

None of these is enough alone, and they are not alternatives. They stack, cheapest first, with the backstop at the bottom.

  1. Ownership, decided before any code. Most fields stop being two-way at all.
  2. Narrow triggers. Events you never subscribe to never arrive.
  3. Echo recognition. Markers for the fields only the sync writes, fingerprints for the rest, and actor checks as a shortcut, never the only guard.
  4. The comparison, every time, immediately before every write. This is what turns any leak above into one wasted lap instead of an endless one.

Then add a ceiling, because a sync that is wrong should be wrong slowly. ScaleXP writes at most 40 records per direction per run, and anything over the cap waits for the next run. A cap will not stop a loop, but it turns a runaway into a slow, visible drip that shows up in a digest long before it becomes an API bill or a rate limit. There is more on what happens when you do hit one in the post on API rate limits.

One problem these layers do not solve: two real edits to the same record arriving at the same moment from both directions. That is a race, not an echo, and it needs a lock or a reconcile step. It has its own post: race conditions in n8n.

The audit, in ten questions

Run these against any sync that writes in both directions.

  • For every synced field, can you name the one system that owns it?
  • Does any field flow both ways with no rule for who wins a conflict?
  • Before every write, does the sync compare against the destination's current value, re-read rather than taken from the payload?
  • Are both sides normalised by the same function before that comparison: case, whitespace, phone formats, dates and time zones, multi select order?
  • When nothing differs, does the run return no items, or does it write the same value back?
  • Does the sync write anything back to the source, like a link or an id, and does it recognise that field when the event comes back?
  • If you filter on who made a change, does an event with several authors survive when one of them is a person?
  • Does anything else share your integration's token or bot, so its edits look like yours?
  • Is any trigger subscribed to more fields or events than the sync actually uses?
  • Is there a per run write cap, and would you notice if a quiet week still wrote hundreds of records?

A sync that passes all ten can still have bugs, but not this one. A sync that fails the first five is looping already, or will be the first afternoon someone edits both sides.

If you would rather have someone else read your sync with this list in hand, that is the kind of thing I do: a 30 minute call, no obligation, and a straight answer about whether your loops are real or theoretical.

Common questions

What causes an infinite loop in a two-way sync?
Each side treats the sync's own write as a new change. System A changes, the sync writes to B, B reports a change, the sync writes to A, and A reports a change again. Webhooks and polls say that a record changed, not why, and not whether the change was the sync's own write, so a sync with no way to recognise its own writes keeps answering itself.
How do I stop an Attio and Notion two-way sync from looping in n8n?
Stack the defences. Decide which system owns each field, subscribe only to the fields you sync, recognise your own writes with a marker field or a fingerprint, and before every write compare normalised values and skip the write when they already match. The comparison is the backstop: a sync that only writes real differences cannot loop for more than one lap.
Can I use a timestamp window to ignore my own updates?
Not reliably. Webhook delivery can take minutes and retries far longer, so your own echo often arrives after the window closes, while a real edit made inside the window gets thrown away. A Notion page's last edited time is also only accurate to the minute. Use timestamps to order events, and use ownership, markers and a comparison before each write to suppress echoes.
How do I tell if a Notion change was made by my integration?
Webhook events carry an authors array typed person, bot or agent, and your integration's bot user id comes from the endpoint that retrieves your token's bot user. Drop an event only when every author is your bot, because aggregated events can carry several authors. When polling, a page records only one last_edited_by, which can hide a person's edit made just before yours.
Is last write wins good enough for a two-way sync?
Rarely. It settles conflicts by whichever write was seen last, which depends on delivery delays, retries and clock resolution rather than on who actually edited last. It also does nothing about echoes. Give each field one owning system, let the owner win, and route the other side's edits to a person as conflicts.
Does a scheduled two-way sync loop too?
Yes, just more slowly. If each run copies A to B and then B to A, and any write counts as a change, the same record crosses back and forth on every run, and every modified date ends up being today. The fix is the same as for webhooks: compare normalised values before writing, and write nothing when they already match.