Two-way sync keeps two systems in parity: changes in System A propagate to System B, and changes in System B propagate to System A.
Two-way sync keeps two systems in parity: changes in System A propagate to System B, and changes in System B propagate to System A. The key challenge is avoiding infinite loops (A changes B, which triggers a change back to A). The solution: track a sync timestamp, compare records by unique key, and only update records that have actually changed since the last sync.
Real-world example: Sync a Notion database (project tracker) with an Airtable base (client-facing dashboard). When a project status changes in either system, the other system updates.
// Inputs: notionRecords (from Notion API) and
// airtableRecords (from Airtable API)
const notionItems = $('Notion - Get Projects').all();
const airtableItems = $('Airtable - Get Projects').all();
const staticData = $getWorkflowStaticData("global");
const lastSync = staticData.lastSyncTimestamp || 0;
const toUpdateInAirtable = [];
const toUpdateInNotion = [];
const toCreateInAirtable = [];
const toCreateInNotion = [];
// Build lookup maps by shared unique key (project_id)
const notionMap = new Map();
notionItems.forEach(item => {
notionMap.set(item.json.project_id, item.json);
});
const airtableMap = new Map();
airtableItems.forEach(item => {
airtableMap.set(item.json.fields.project_id, item.json);
});
// Compare records
for (const [projectId, notionRecord] of notionMap) {
const airtableRecord = airtableMap.get(projectId);
if (!airtableRecord) {
// Exists in Notion but not Airtable -- create in Airtable
toCreateInAirtable.push({ json: notionRecord });
continue;
}
const notionUpdated = new Date(notionRecord.last_edited).getTime();
const airtableUpdated = new Date(
airtableRecord.fields.last_modified
).getTime();
if (notionUpdated > airtableUpdated && notionUpdated > lastSync) {
// Notion is newer -- update Airtable
toUpdateInAirtable.push({
json: {
airtable_record_id: airtableRecord.id,
...notionRecord
}
});
} else if (airtableUpdated > notionUpdated && airtableUpdated > lastSync) {
// Airtable is newer -- update Notion
toUpdateInNotion.push({
json: {
notion_page_id: notionRecord.notion_id,
...airtableRecord.fields
}
});
}
}
// Check for records in Airtable but not in Notion
for (const [projectId, airtableRecord] of airtableMap) {
if (!notionMap.has(projectId)) {
toCreateInNotion.push({ json: airtableRecord.fields });
}
}
// Update sync timestamp
staticData.lastSyncTimestamp = Date.now();
// Output to four branches
return [toUpdateInAirtable, toUpdateInNotion,
toCreateInAirtable, toCreateInNotion];
```text
```text title="Workflow Structure"
Schedule Trigger (every 5 min)
|
+-> Notion - Get All Projects
+-> Airtable - Get All Projects
|
Merge (wait for both)
|
Code Node (sync logic above) -> 4 outputs:
Output 1 -> Airtable - Update Records
Output 2 -> Notion - Update Pages
Output 3 -> Airtable - Create Records
Output 4 -> Notion - Create Pages
```text
> **Warning: Conflict Resolution**
>
> When the same record is modified in both systems between sync intervals, you have a conflict. The pattern above uses "last write wins" based on timestamp. For more sophisticated conflict resolution, add a review queue: flag conflicting records and send them to a human via Slack or email for manual resolution.
This pattern generalizes to any pair of systems that expose list and update operations.
**Related:** [Use Path Parameters in Webhook URLs for Dynamic Routing](../webhook-mastery/01-use-path-parameters-in-webhook-urls-for-dynamic-routing.md) | [Use Edit Fields in "Map Each" Mode for Simple Renames](../data-transformation/01-use-edit-fields-in-map-each-mode-for-simple-renames.md)
I build production n8n and Cloudflare automation for teams — the same engineering behind HarperFlow. Fixed-price, escrow-protected, US-based.