Article
Two-Way Sync Without Infinite Loops: Building a Loop-Safe Bi-directional Sync Layer with n8n and Postgres
The standard approach to bi-directional synchronization between a transactional database and a CRM usually starts with two webhooks and an optimistic outlook. It almost always ends in an "echo chamber" effect: System A updates a record, which triggers a webhook to System B. System B updates its record, which triggers a webhook back to System A.
Unless your integration is architected to recognize its own reflection, this cycle repeats until your API limits are exhausted or your data is a corrupted mess of last_modified_date timestamps.
Building a resilient two-way sync requires moving beyond simple point-to-point triggers. To build a system that is safe, predictable, and idempotent, you need a middleware layer that maintains state. Using n8n as the orchestrator and PostgreSQL as the state store, we can implement three specific safeguards: origin tracking, payload hash diffing, and atomic update locking.
The Mechanism of the Echo Loop
Most modern CRMs are professional gossips. When a property changes in HubSpot or Salesforce, the platform emits a webhook. These platforms generally do not distinguish between a change made by a human user in the UI and a change made via an API call from your integration.
Without a filter, your integration treats its own previous update as a "new" external change. This creates a recursive loop. Even if the data values don't change after the first cycle, the metadata—such as last_modified_date—usually does, ensuring the next webhook fires regardless. You aren't just syncing data; you're syncing the fact that you just synced data.
Safeguard 1: Origin Tracking
Before implementing complex logic, use the metadata provided by the CRM. Leading platforms include fields in their webhook payloads that identify the source of the change.
In HubSpot, the changeSource property in a webhook payload indicates whether the update came from the CRM_UI, an INTEGRATION, or the API. A loop-safe sync should immediately terminate if the changeSource matches your integration’s own identity. Similarly, Salesforce Change Data Capture (CDC) events include a changeOrigin field containing the API client ID.
However, origin tracking is a shallow defense. It doesn’t protect you from "jitter"—situations where multiple updates happen in rapid succession from different sources—nor does it handle scenarios where you are syncing multiple systems that might all be categorized as "API" updates. For those, we need a stateful middleware table.
Safeguard 2: Payload Hash Diffing
Even when a change is legitimate (e.g., a rep updates a phone number), CRMs often send the entire record or a large subset of properties in the webhook. If your backend database only cares about a few specific fields, you should not trigger an update process unless those specific fields have actually changed.
Payload hashing allows you to ignore "noisy" webhooks. In your PostgreSQL instance, create a sync tracking table:
CREATE TABLE sync_state (
internal_id UUID PRIMARY KEY,
external_id VARCHAR(255) UNIQUE,
last_sync_hash TEXT,
sync_locked_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
When a webhook arrives in n8n, follow this logic:
- Extract and Sort: Extract only the fields you intend to sync (e.g.,
email,plan_type,status). Sort the keys alphabetically to ensure the stringified object is deterministic. - Hash: Generate a SHA-256 hash of the stringified fields.
- Compare: Compare the new hash against the
last_sync_hashin your Postgres table.
If the hashes match, the incoming webhook contains no new information for your specific use case. You can acknowledge the webhook and terminate the workflow. This significantly reduces the load on your backend API and prevents redundant write operations.
Safeguard 3: Atomic Update Locking
Bi-directional syncs are prone to race conditions. If a customer updates their email in your app at the exact millisecond a sales rep updates the same customer's status in the CRM, two workflows will trigger simultaneously. Both will attempt to update the opposing system, potentially leading to a "lost update."
Atomic locking in PostgreSQL ensures that only one sync process can act on a specific record at a time. Instead of a simple SELECT, use a conditional update that acts as a lock. In n8n, use a Postgres node to execute:
UPDATE sync_state
SET sync_locked_at = NOW()
WHERE external_id = $1
AND (sync_locked_at IS NULL OR sync_locked_at < NOW() - INTERVAL '1 minute')
RETURNING *;
If the query returns a row, the workflow has acquired the lock. If it returns nothing, another process is currently syncing that record, and the current execution should terminate. This prevents the "ping-pong" effect where two systems fight over the same record state.
Implementation in n8n
In a production n8n environment, the workflow sequence follows this path:
- Webhook Trigger: Receive the CRM update.
- Origin Filter: Check if the source is your integration. Filter if true.
- Get State: Query the Postgres
sync_statetable by the CRM ID. - Hash & Compare: Generate the hash. If
new_hash == last_sync_hash, stop. - Acquire Lock: Attempt the conditional
UPDATEonsync_locked_at. - Apply Update: Send the data to your backend system.
- Release & Record: Update
sync_statewith the new hash and setsync_locked_atback toNULL.
Assessing the Trade-offs
This architecture requires more effort than a native HubSpot-Salesforce sync or a standard Tray.io recipe. Enterprise iPaaS tools often handle loop prevention as a "black box," which is convenient until it fails and you have no visibility into why.
Building this in n8n and Postgres provides three distinct advantages:
- Granular Control: You define exactly what constitutes a "change." You can exclude noisy fields from the hash to prevent unnecessary syncs.
- Auditability: You have a complete record in Postgres of when every record was last synced. This is invaluable for debugging why a specific record didn't update.
- Cost Efficiency: For high-volume syncs, paying per-task on enterprise iPaaS is expensive. Self-hosting n8n and a small Postgres instance provides better reliability for a fraction of the cost.
The primary drawback is maintenance. Your team is now responsible for the uptime of the Postgres database and the logic within the sync table. For non-technical RevOps teams, this overhead might outweigh the benefits. For GTM engineering teams, this pattern offers a deterministic way to solve the echo chamber problem once and for all.
Conflict Resolution
When a collision occurs—where hashes change simultaneously—you need a tie-breaker. A common GTM engineering pattern is "Master of Truth" logic. For instance, you might decide your transactional database is the master for billing_plan, while the CRM is the master for owner_id. By checking the source system before applying the update, your middleware can resolve conflicts based on business logic rather than mere chronological luck.
— C.B.