Skip to content
All work

Backend · API · Live

Identity Reconciliation Engine

The same customer, four email addresses, two phone numbers, six orders. One endpoint that decides they are one person — in two queries, however tangled the history.

Built

2026

Stack

  • TypeScript
  • Express 5
  • Prisma
  • PostgreSQL

Hosted on a free tier — the first request after idle takes around 25 seconds to wake.

01 — The problem

Linked records want to become a graph, and a graph wants to be walked.

A customer orders once with a personal email, once with a work email and the same phone, once with a new phone and the work email. Each order links a pair. Do that enough times and the contacts form a graph — and the obvious implementation walks it on every lookup.

That works and it gets slower in a way that is hard to see coming, because the cost depends on how tangled a particular customer’s history happens to be. The customer with the most orders — the one you least want to keep waiting — has the deepest chain.

A walkthrough of the reconciliation: separate contacts merging into one primary with secondaries pointing directly at it.
Two clusters meeting, and the merge that flattens them. · docs/reconciliation-walkthrough.png
02 — The decision

Hold an invariant instead of walking a graph.

The invariant is one sentence: a secondary contact always points directly at its primary, never at another secondary. Link depth is therefore always exactly one, and a lookup is two queries — find the matching contacts, then fetch that cluster — no matter how many merges the cluster has survived.

The cost does not disappear; it moves. When two clusters merge, every secondary on the losing side is repointed at the surviving primary in the same transaction. That is union-find with eager path compression, with the work paid at write time, where it is rare and bounded, instead of at read time, where it is constant and unbounded.

Queries per lookup
2
regardless of merge history
Link depth
1
held as an invariant, not an average
Endpoint
POST /identify
one route, one responsibility
Compression
Eager
paid at merge time, inside the transaction

What it deliberately does not do

  • It does not walk the contact graph. There is no recursive traversal anywhere, by design. If the invariant ever broke, the two-query guarantee would break with it — which is why the merge happens inside a transaction rather than as a follow-up job.

  • It does not run on paid infrastructure. The deployment is a free Render instance that sleeps when idle. The first request after a quiet period takes around 25 seconds — a hosting choice, not a property of the service.