← All articles

GTM Engineering

Routing Is Latency. Every Minute You Sit On a Lead, the Odds Decay.

Round-robin whenever someone gets to it treats routing as a fairness formula. It is not. Routing is latency, and contact odds decay by the minute. Here is the decay curve, the queue design that survives its failures, and a worked example that reconciles to the meeting count.

· 15 min read

A lead came in at 2:14pm and got assigned twice. Two reps called the same buyer within a minute of each other, the buyer got annoyed, and the deal never happened. Nobody wrote a bug that assigns leads twice. The form fired a webhook, the webhook timed out, the retry fired, and the second delivery hit before the first one finished writing the owner. Two deliveries, two assignments, one confused buyer. Most teams file that under “routing is messy” and move on. The old way of thinking about routing is that it is a fairness problem: whose turn is it, distribute evenly, round-robin whenever someone gets to it.

Here is the break. Routing is not a fairness problem, it is a latency problem, and latency is measured against a curve that punishes you by the minute. The 2:14pm double-call cost you nothing on a fairness ledger, both reps got a lead. It cost you everything on the latency curve, because while the two of them were untangling who owned the buyer, the buyer’s intent was decaying in real time. Every reliability failure in your routing path (a duplicate delivery, a dropped lead, a retry storm, a mid-chain crash) shows up as the same thing on the buyer’s side: minutes of silence. Watch what those minutes do to your odds.

Qualify odds vs minutes to first touchThe intent you routed is decaying while the lead sits in a queue
02550751001 min5 min10 min30 min1 hr24 hrMinutes from form submit to first human contact
1001 minPeak. The buyer just raised a hand and is still on the page.
Relative odds of qualifying a lead, indexed to 100 inside the first five minutes. The 5-to-30-minute cliff is the classic Lead Response Management finding (Oldroyd / InsideSales), treated here as a heuristic, not a law. The shape is the point: latency is not linear, it falls off a cliff.
21x
Drop in qualify odds from 5 to 30 minutes to first touch (Lead Response Management study, treat as heuristic)
63.5%
Of 1,000 B2B sites never responded to a demo request; average response ran ~29 hours (RevenueHero, 2025)
1 key
An idempotency key kills the double-assign that adds hours of latency to a hot lead

The decay curve is why routing reliability is a revenue problem and not a hygiene problem. A system that double-assigns, drops, or stalls a lead does not just annoy a rep. It parks a hot buyer on the flat part of that curve, where the odds are already gone. So the build has two jobs that are the same job: get to first touch fast, and never let a failure between the form and the rep quietly add minutes. To do the second one you have to stop treating routing like a spreadsheet formula and start treating it like the thing it actually is.

Routing is a distributed system, and every hop is a failure domain

A lead crosses a web form, a message queue, a CRM, an enrichment API, a scoring service, and a Slack notifier before a rep ever sees it. Each of those hops can be up while the next is down, can succeed while the caller thinks it failed, and can be retried by something upstream that never learned the first attempt worked. That is the textbook definition of a distributed system, and it has been one the whole time.

Web form
Webhook / queue
Enrichment API
Scoring service
CRM assignment
Slack + SLA timer
Six services, six independent failure domains. A guarantee that holds inside one box says nothing about the boundary between two boxes.

The naive design treats this as one synchronous chain: the form calls enrichment, enrichment calls scoring, scoring writes the owner, all in a single request. When any link is slow, the whole chain blocks, and every second it blocks is a second on the decay curve. When the caller times out, it retries the whole chain and re-runs the writes that already succeeded, which is exactly how you get the 2:14pm double-assign. Synchronous chains do not fail gracefully. They fail by duplicating and by dropping, the two failure modes that either double-call a buyer or strand one in silence.

The queue is the seam that owns the failures

The fix is to stop routing inline and route through a durable queue. The form does one thing: write a routing job and return fast. A separate worker drains the queue, does the enrichment and scoring and assignment, and marks the job done. That seam between “event arrived” and “work happened” is where every reliability property lives: dedup, retry, ordering, and a dead-letter path for jobs that will never succeed. Returning fast also helps the latency curve directly, because the buyer-facing acknowledgment happens in milliseconds while the slow work drains behind it.

The routing queue Intake writes a job, a worker drains it, failures dead-letter
Intakeform / webhookenqueueRouting_Job__cqueue + idem keystatus, attemptsdrainWorkerenrich, score,assign ownermax attempts exceededDead-letter queue
The form never assigns. It enqueues an idempotent job. The worker owns the work and the retries. Poison jobs land in a dead-letter queue a human reviews, not in a rep's lap.

The artifact: a routing job table

The queue is a table. On Salesforce I build it as a custom object; on a warehouse stack it is a table with the same columns. The columns are what give you every guarantee, so here is the schema, not a description of it.

CREATE TABLE routing_job (
  id              BIGINT PRIMARY KEY,
  idempotency_key TEXT NOT NULL UNIQUE,   -- dedup: same key = same job, ever
  lead_id         TEXT NOT NULL,
  payload         JSONB NOT NULL,          -- the event as received
  status          TEXT NOT NULL DEFAULT 'queued', -- queued|working|done|dead
  attempts        INT  NOT NULL DEFAULT 0,
  max_attempts    INT  NOT NULL DEFAULT 5,
  next_run_at     TIMESTAMPTZ NOT NULL DEFAULT now(), -- backoff schedule
  locked_by       TEXT,                    -- worker id holding this job
  locked_at       TIMESTAMPTZ,
  ordering_key    TEXT,                     -- per-account FIFO if needed
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at    TIMESTAMPTZ
);
CREATE UNIQUE INDEX ON routing_job (idempotency_key);

The idempotency_key column carries the whole dedup guarantee. Derive it from something stable in the event, not from a random ID the sender generates fresh on each retry. For a form fill I use a hash of email plus form ID plus a coarse time bucket, so two deliveries of the same submission collapse to the same key and the second insert simply violates the unique constraint. The double-assign that opened this piece cannot happen, because the second job was never created.

The worker claims work with an atomic lock so two workers never grab the same job:

UPDATE routing_job
SET status = 'working', locked_by = :worker_id, locked_at = now(), attempts = attempts + 1
WHERE id = (
  SELECT id FROM routing_job
  WHERE status = 'queued' AND next_run_at <= now()
  ORDER BY created_at
  FOR UPDATE SKIP LOCKED           -- two workers never claim the same row
  LIMIT 1
)
RETURNING *;

FOR UPDATE SKIP LOCKED is the line that lets you run ten workers in parallel with zero coordination and zero double-processing. Each worker skips rows another worker already locked. Ten workers draining in parallel is also how you keep median first-touch on the left side of the decay curve under a burst of inbound: the queue absorbs the spike, the workers fan out, and no lead waits on a single-threaded chain.

Idempotency at the write, not just the enqueue

Deduping the job is half the battle. The write to the CRM has to be idempotent too, because a worker can crash after it assigns the owner but before it marks the job done, and the retry will re-assign. Make the assignment a conditional write: only set the owner if it is still unassigned, or record the assignment decision keyed by the same idempotency key so replaying it is a no-op. On Salesforce that looks like a guarded update.

// Idempotent assignment: the worker can replay this safely.
Lead ld = [SELECT Id, OwnerId, Routing_Idem_Key__c FROM Lead WHERE Id = :leadId FOR UPDATE];
if (ld.Routing_Idem_Key__c == idemKey) {
    return; // already routed by this exact job; replay is a no-op
}
if (isUnassignedOrQueue(ld.OwnerId)) {
    ld.OwnerId = chosenOwnerId;
    ld.Routing_Idem_Key__c = idemKey;   // stamp so a replay short-circuits
    update ld;
}

Stamping the lead with the key that assigned it means a replay reads the stamp and returns. The buyer gets one call, from one rep, no matter how many times the queue redelivers. This is the same idempotency discipline that keeps an automation from running twice and double-charging you; routing is the highest-stakes place to apply it, because the duplicate is not a wasted credit, it is a lost deal.

Retry with backoff, then dead-letter

Not every failure should retry the same way, and no failure should retry forever. An enrichment API 500 is transient: retry it with exponential backoff. A malformed payload is permanent: retrying it a thousand times just burns the queue and, worse, keeps a routable lead behind it waiting. The attempts and next_run_at columns encode the policy.

Failure typeExamplePolicy
TransientEnrichment API 503, CRM row lockRetry with exponential backoff: 1m, 4m, 16m, 64m
Rate limit429 with Retry-After headerHonor the header, requeue at that time
PermanentMalformed payload, unknown leadDo not retry, dead-letter immediately
PoisonSucceeds partway, crashes worker every timeDead-letter after max_attempts

When attempts hits max_attempts, the job moves to dead, and a human reviews the dead-letter queue. The critical design choice: a poison job lands in a review queue, never in a rep’s lap and never dropped silently. A lead that could not be routed is a lead someone has to look at, not a lead that vanished onto the flat part of the decay curve.

What ordering buys you, and when to pay for it

Sometimes order matters. Two events for the same account arriving out of order can assign the account to the wrong owner if the later event lost the race. The ordering_key column lets you enforce per-account FIFO: the worker will not start a job whose ordering key has an earlier unfinished job. You pay for this with throughput, and throughput is latency, so only turn it on for keys where order changes the outcome. Most lead events are independent and do not need it. Account-merge and owner-change events do.

A worked example that reconciles to the curve

Take 1,000 inbound demo requests a month and run them down both architectures. The synchronous chain assigns inline, blocks on every slow hop, and re-runs on every timeout. The queued system enqueues in milliseconds and drains with ten parallel workers. The qualify-odds column reads directly off the decay curve at the top of this piece, at whatever minute each path actually reaches first touch.

PathMedian minutes to first touchDuplicate / dropped leadsQualify-odds index at that minuteQualified leads per 1,000Meetings held
Synchronous chain47 min~76 (double-assign + silent drops)~1515038
Queued system6 min~2~95950… capped by fit240

The synchronous chain reaches a median buyer at 47 minutes, deep into the cliff where the index has fallen to about 15, and it double-assigns or drops roughly 76 leads a month that never get a clean first touch at all. The queued system reaches the median buyer at 6 minutes, near the 95 mark on the curve, and its duplicate rate is rounding error. Same 1,000 leads, same reps, same enrichment. The only thing that changed is that one architecture keeps leads on the left of the decay curve and the other lets them slide down it while a retry storm sorts itself out. The 240-versus-38 meeting gap is the decay curve priced in dollars.

Where the duplicates actually come from

Before you build any of this, measure it. Most teams assume their routing is clean because nobody filed a ticket, but the double-assigns are quietly happening and the buyers are quietly leaving. This is roughly the breakdown I find when I audit a synchronous routing path.

Routing failures by root cause (typical audit)
Duplicate deliveries and mid-chain crashes dominate. Both vanish under a queue with idempotency and locks. Bad payloads are a smaller share but they are the ones that fail silently and strand a lead.
View as table
ItemValue
Duplicate delivery41%
Mid-chain worker crash27%
Enrichment timeout18%
Out-of-order events8%
Malformed payload6%

Synchronous chain vs queued system

Same six services. Two architectures. One survives production, and it is the one that keeps leads off the flat part of the curve.

Synchronous chain Queued system
A slow enrichment call Blocks the whole chain, times out, retries everything Job waits in queue, worker retries just that step
Duplicate delivery Second assignment, two reps call one buyer Unique idempotency key, second job never created
Worker crash mid-run Lead half-routed, owner set, no notification Job stays working, lock expires, another worker resumes
A permanently bad payload Retries forever or fails silently Dead-letters after max attempts, human reviews
Median time to first touch Tens of minutes, deep on the decay curve Single-digit minutes, near peak odds
Where a failure lands On the buyer, as a missed or doubled call In a queue, as a job with a status
The right column is not more code. It is the same work with a seam that owns the failures instead of letting them reach the buyer as latency.

The build order: ship it in five steps

Five steps, and you can ship them incrementally. The queue and idempotency key alone kill the majority of the failures and the latency they add, so build those first and add the rest as the dead-letter queue fills. The order is not arbitrary: it is sorted by how much latency each failure injects into the curve, worst first.

The reliable routing queue
  1. 1

    Put a durable queue between intake and work

    The form or webhook does one thing: write a routing job and return in milliseconds. All enrichment, scoring, and assignment moves to a worker that drains the queue. This is the seam that owns every reliability property and keeps intake latency near zero.

  2. 2

    Derive an idempotency key from stable event data

    Hash email plus form plus a coarse time bucket, not a random per-request ID. Make it a unique constraint so a duplicate delivery fails to insert and collapses to a no-op. This one column kills the double-assign, the single failure that both wastes a rep and burns the buyer.

  3. 3

    Claim jobs with an atomic lock and run many workers

    Use FOR UPDATE SKIP LOCKED (or a claimed-by stamp on Salesforce) so many workers run in parallel and never grab the same job. Parallel drain is what holds median first-touch on the left of the decay curve under a burst.

  4. 4

    Make the CRM write itself idempotent

    Stamp the lead with the idempotency key that assigned it and short-circuit on replay. A worker that crashes after assigning but before marking done must be safe to re-run, or the retry re-assigns and you are back to two reps on one buyer.

  5. 5

    Classify failures, back off, dead-letter

    Retry transient failures with capped exponential backoff, do not retry permanent ones, and move anything past max attempts to a dead-letter queue a human reviews inside two hours. A lead that cannot route is reviewed while it still has odds, never dropped.

The latency argument on the operations side makes the case that routing has to be fast. This is the other half: it has to be reliable while it is fast, because a fast system that double-assigns or drops leads just reaches the wrong outcome sooner, and the buyer still slides down the curve. Treat routing like the distributed system it is, borrow the queue, the idempotency key, and the dead-letter path from the people who solved this a decade ago, and the 2:14pm double-call stops happening. Start this week with one table and a unique index on the idempotency key, then let the dead-letter queue tell you what to build next. The decay curve is not waiting for your retries to settle.

lead-routing architecture reliability

Keep reading

One email. Every week.

One email a week: a system I built or broke, with the config, the numbers, and what I would change. No roundups, no theory, unsubscribe whenever it stops being useful.

The newsletter opens soon.

Connect a provider in src/config.ts