DEV Community

life
life

Posted on

A hold-and-release queue for orders waiting on paperwork

Every fulfillment system eventually meets the order that cannot ship for a boring reason. The battery datasheet has not arrived. The commercial invoice lists a country of origin the buyer disputes. The destination needs a declaration the seller never filed. The order is real, the stock is real, and nothing will move until a document shows up.

Most implementations handle this badly. Somebody puts a note on the order, which means the order keeps flowing through the pick wave and gets pulled back at the pack bench. Somebody else adds a boolean called on_hold, which is worse, because a boolean cannot tell you why, who is chasing it, or when it stops being acceptable to wait.

A hold is a state with a reason, not a flag

The model that survives contact with a real warehouse is small. A hold has a reason code, an owner, a created time, an optional expiry, and a set of orders it applies to. Orders are not held, they carry holds, which sounds like a distinction without a difference until the first time one order needs two of them.

create table holds (
  id            uuid primary key,
  order_id      uuid not null references orders(id),
  reason        text not null,          -- doc_missing, declaration_required, address_unverifiable
  detail        jsonb not null default '{}',
  owner         text,                   -- who is chasing it
  opened_at     timestamptz not null default now(),
  expires_at    timestamptz,            -- what to do if nothing arrives
  released_at   timestamptz,
  released_by   text
);

create unique index one_open_hold_per_order_reason
  on holds (order_id, reason) where released_at is null;
Enter fullscreen mode Exit fullscreen mode

The partial unique index does the quiet work. Without it, the same missing document discovered by three different checks creates three open holds, and releasing one leaves the order stuck with no visible reason. With it, the second insert fails, and the code path that found the problem learns that somebody already found it.

Where the check belongs

Release the hold at the last moment that still allows a release to be cheap. Too early and you block orders that would have cleared anyway, which is what starves your pick rates. Too late and a picker walks a carton to the bench, scans it, and has it pulled back from them.

In practice that means two gates rather than one.

Gate Question Cost of being wrong
Wave release Can this order be picked at all? A wasted walk, recoverable
Label purchase Will this order be allowed to leave the building? A label paid for, a parcel to retrieve

The label gate is the expensive one, and it is exactly where most systems stop checking. If the label prints, the parcel is committed, so a document hold discovered after that point is already a reverse-logistics problem. Check holds immediately before calling the rating and label API, not when the order was imported.

Expiry is the part people skip

A hold without an expiry is a decision you refused to make. Attach one and the queue becomes actionable, because now you can ask a question that has a real answer: what happens to this order if nothing arrives in five days?

The answers are ordinary. Cancel and restock. Ship anyway, if the missing document turns out not to be legally required for that lane, which is a rules question rather than a judgment call. Escalate to a human with the clock already running. Whatever you pick, encode it as an action on the hold rather than as a habit, because the alternative is a queue that nobody trusts to be looked at.

// runs every few minutes; the point is that a hold cannot silently age
async function sweepExpiredHolds(now) {
  const expired = await db.query(
    `select id, order_id, reason from holds
      where released_at is null and expires_at is not null and expires_at < $1
      for update skip locked`, [now]);

  for (const hold of expired) {
    const action = POLICIES[hold.reason]?.onExpiry ?? 'escalate';
    await apply(action, hold);        // cancel | ship_anyway | escalate
    await audit.record({ type: 'hold_expired', holdId: hold.id, action });
  }
}
Enter fullscreen mode Exit fullscreen mode

for update skip locked matters if you run more than one worker. Without it, two sweeps pick up the same expired hold and the order gets cancelled twice, or cancelled and shipped, which is the worst of the combinations.

The reporting that makes it tolerable

Two numbers, on one screen, refreshed continuously. How many orders are currently held, broken by reason. And how long each reason typically takes to clear, as a median over the last thirty days rather than a snapshot.

The first number tells you whether you are meeting the day's volume. The second tells you which reason is actually a process problem. A reason code that clears in hours is a document you can chase. A reason code whose median is nine days is a supplier relationship or a data-collection step you have never fixed, and no amount of queue engineering will shorten it.

That is the whole design. Holds as rows rather than flags, two gates instead of one, an expiry that forces a decision, and a median that tells you what to fix upstream.


FulfillNexa by SBT (fulfillnexa.com) is a China-based cross-border 3PL running three China warehouses totalling 24,000 m², with Suzhou at 13,000 m² for supplier consolidation, Dongguan at 8,000 m² for ecommerce warehousing and one-piece fulfillment, and Shenzhen at 3,000 m² for oversized cargo and sea-air work. Rates, product acceptance and delivery arrangements are confirmed per shipment.

Top comments (0)