Stop echo loops in two-way syncs between two systems, for example a CRM and an ERP or e-commerce platform. Three independent guards (origin tagging, content-hash comparison and a short suppression window), a pluggable store, and no runtime dependencies. TypeScript, Node 20 or later.
import { createGuard } from "sync-loop-guard";
const guard = createGuard({ origin: "sync-bot" });
const decision = await guard.shouldPropagate({
source: "crm",
recordId: "42",
fields: { email: "ada@example.com" },
origin: "user:alice",
});
if (decision.propagate) {
await guard.recordWrite({ target: "shop", recordId: "42", fields: decision.fields });
// ...now write decision.fields to the shop
}A two-way sync listens for changes in both systems and copies them across. Each copy is itself a change, so the other system reports it, and the sync copies it back:
change ──▶ webhook ──▶ sync ──▶ write
in CRM to Shop
▲ │
│ ▼
write ◀── sync ◀── webhook ◀── change
to CRM in Shop
Left alone this is one of two things:
- An endless loop. Every round trip burns API quota, fills logs, and ends when a rate limit or an error stops it.
- A short loop that damages data. The echo stops after one round trip, but while it was in flight someone edited the record again. The stale echo overwrites their edit.
The fix is for the sync to recognise its own writes when they come back. No single check does that reliably across systems, because each system differs in what it tells you about a change and in how it rewrites values. So this library combines three.
| Guard | Drops an event when... | Needs | Weak spot |
|---|---|---|---|
| Origin tagging | it says the sync made the change | the source reports who or what changed a record | systems that do not say; a shared service account used by people and the sync |
| Content-hash comparison | its values equal the last synced values | a store that survives restarts and is shared by workers | values the other system reformats |
| Suppression window | the sync wrote that record and field to that system moments ago | a store with expiry | a real edit inside the window is lost |
Every write the sync makes carries a marker: an integration user, an API client
name, a header, or a dedicated field such as syncOrigin. When an event arrives
carrying that marker, the sync made the change, and it is dropped.
Use it when the system reports the origin of a change on its webhook. It is exact, needs no state, and costs nothing. It is not enough on its own because many systems do not report an origin on every object, and if the sync writes as the same user that people log in as, you cannot tell them apart.
After each write the sync stores a hash of the value, per record and field. When an event arrives it hashes the incoming value and drops the field if it matches. Only fields that really changed are passed on.
Use it when the system does not report an origin, when webhooks carry the whole
record every time anything on it changes, and when the same webhook is
delivered twice. It also keeps unrelated edits from rewriting every field. It
needs a store that outlives the process, and it only works if both sides hold
the value the same way. If the other system trims, re-cases or reformats on
save, pass normalize so those differences do not count.
When the sync writes a field to a system, it ignores that system's events for the same record and field for a short time, 10 seconds by default.
Use it when the other system rewrites values in ways you cannot normalise (so the hash will not match), or when the echo can arrive before you have saved the new state. It is the bluntest guard: an edit a person makes in that system inside the window is dropped too. Keep the window short, longer than the time the echo takes to arrive and no longer, and keep the key narrow, which here is one record and one field in one system.
Use all three unless you know why not. They fail in different places, so each covers the others' gaps:
- The system reports an origin and never reformats values: origin tagging
alone is enough. Pass
windowMs: 0, compareHashes: false. - No origin, values stored as sent: content-hash comparison. Pass
windowMs: 0. - Values are reformatted and you cannot normalise them: the window, with hashes as a backstop. Without a window, the hash approach needs one extra write to converge, then stops.
The test suite (test/simulation.test.ts) runs two simulated systems that fire
a webhook for every write and shows each case: no guard loops until a cap is
hit, each guard stops it in its own situation.
npm install github:yourprivatedevs/sync-loop-guardNot on the npm registry; npm installs it from GitHub and builds it on install. Requires Node 20 or later. ES modules only.
Call shouldPropagate when an event arrives from either system. If it says to
propagate, call recordWrite before writing to the other system, because
the echo webhook can arrive before your write call returns. recordWrite hands
back an undo function, so a failed write does not leave behind a record that
claims the value was synced.
import { createGuard } from "sync-loop-guard";
const guard = createGuard({
origin: "sync-bot", // the tag the sync stamps on its writes
windowMs: 10_000,
normalize: (field, value) =>
field === "email" && typeof value === "string" ? value.trim().toLowerCase() : value,
// store: yourSharedStore, // see "Store" below
});
// CRM webhook handler. The shop handler is the mirror image.
export async function onCrmChange(payload: CrmWebhook): Promise<void> {
const recordId = payload.customerId;
const decision = await guard.shouldPropagate({
source: "crm",
recordId,
fields: { email: payload.email, phone: payload.phone },
origin: payload.changedBy, // whatever the CRM reports as the actor
});
if (!decision.propagate) {
console.log("not propagating", decision.dropped); // { email: "suppressed" }, ...
return;
}
const undo = await guard.recordWrite({ target: "shop", recordId, fields: decision.fields });
try {
await shop.updateCustomer(recordId, { ...decision.fields, syncOrigin: guard.origin });
} catch (error) {
await undo();
throw error;
}
}recordId must be the same on both sides. If the two systems use different
ids, map them to one before calling the guard.
| Option | Default | Meaning |
|---|---|---|
origin |
off | Tag the sync stamps on its writes. Events carrying it are dropped. |
windowMs |
10000 |
Suppression window in milliseconds. 0 turns it off. |
compareHashes |
true |
Drop values equal to the last synced value. |
normalize |
none | (field, value) => value, applied before hashing. |
store |
MemoryStore |
Where state is kept. |
Returns a guard with:
guard.origin: the configured tag, to stamp on writes.guard.shouldPropagate(event): resolves to aDecision. Reads state only.guard.recordWrite(write): records an outgoing write and resolves to an asyncundofunction.
An event is { source, recordId, fields, origin? }. A write is
{ target, recordId, fields }.
A Decision is:
{
propagate: boolean; // write decision.fields to the other system
fields: Record<string, unknown>; // the fields that really changed
dropped: Record<string, "own-origin" | "suppressed" | "unchanged">; // the rest, and why
}Checks run in order: origin first, which drops the whole event, then per field
the window, then the hash. Values are compared as JSON, with object keys
sorted. undefined and null count as the same empty value. Dates compare by
their ISO string. Use normalize for anything else.
The guard keeps its state in a GuardStore, which has three methods and may be
synchronous or async:
interface GuardStore {
get(key: string): string | undefined | Promise<string | undefined>;
set(key: string, value: string, ttlMs?: number): void | Promise<void>;
delete(key: string): void | Promise<void>;
}MemoryStore is the in-process implementation, for tests, prototypes and a
single worker. It loses its state on restart. With more than one worker, or
anything that restarts, use a shared store, or an echo handled by a different
worker looks like a new change. A Redis adapter is a few lines, for example with
ioredis:
import type { GuardStore } from "sync-loop-guard";
import type { Redis } from "ioredis";
export const redisStore = (redis: Redis): GuardStore => ({
get: async (key) => (await redis.get(key)) ?? undefined,
set: async (key, value, ttlMs) => {
if (ttlMs === undefined) await redis.set(key, value);
else await redis.set(key, value, "PX", ttlMs);
},
delete: async (key) => {
await redis.del(key);
},
});MemoryStore takes { now } for a controllable clock in tests:
new MemoryStore({ now: () => fakeTime }).
- Conflict resolution. If two people change the same field in both systems at once, something still has to decide which wins. This library only stops the sync from echoing its own writes.
- Webhook authentication or delivery ids. Verify signatures and drop duplicate deliveries by id yourself if the system provides ids. Content hashes also absorb repeated deliveries of the same value.
- Nested merging. A field is one value. To sync part of an object, make that part its own field.
npm install
npm test # compiles with tsc, then runs node:test
npm run build # emits dist/MIT, copyright Your Private Dev. Built by Your Private Dev, a one-person engineering service for workflow automation, AI agents and integrations.