Skip to article

The cart is the hardest problem in travel

A flight, a hotel and a transfer in one cart looks like ecommerce. It is actually a distributed transaction across suppliers that never agreed to coordinate, with inventory that expires while the customer types a card number.

Every team estimates the travel cart at two weeks. It draws like solved ecommerce: a flight card, a hotel card, a transfer card, one total, one button, and that resemblance has probably cost this industry more engineering time than any other single screen.

A retail cart holds items that exist. The warehouse has the shoes. The price is the price. Between "add to cart" and "pay," almost nothing changes, and when it does the store absorbs it silently because the margin allows it.

A travel cart holds promises from three or four independent companies, none of which know the others exist. The airline's fare can reprice while the traveller reads the cancellation policy. The bedbank's hold expires on a timer the airline has never heard of. The activity supplier offers no hold at all. Then one button has to commit all of them, atomically from the traveller's point of view, across systems that offer no atomicity whatsoever.

That is not ecommerce. That is a distributed-systems problem wearing an ecommerce costume.

A cart is a set of decaying options

Start by giving up the word "item." Cart items are not items. They are options with expiry dates, and every supplier sets a different one without telling you.

A low-cost carrier fare is typically not held at all. The quote you show is a snapshot of a yield-management system that reprices continuously. Treat it as accurate for minutes, sometimes less on high-demand routes. A full-service carrier via NDC may give you a priced offer with an explicit validity window. A bedbank hold is often a real reservation with a countdown, twenty minutes being a common shape. A tours-and-activities supplier frequently offers no hold state at all: you find out the real price and availability at the moment you try to book.

ComponentTypical holdReprice behaviourRisk at commit
LCC fareNoneContinuous, can move in minutesHigh: price or seat gone
NDC offerExplicit offer expiryFixed until expiry, then re-shopMedium: bounded by offer TTL
Bedbank roomTimed hold, often ~20 minStable during holdLow during hold, high after
ExperienceNoneChecked at booking timeMedium: capacity-driven

So the cart's core data structure is not a list of line items. It is a set of decaying options, each on its own clock, and the cart's only real job is to keep the traveller inside the shrinking window where all of them are still simultaneously true.

T+0 T+10m T+20m T+30m FLIGHT · LCC FARE no hold · quote only HOTEL · BEDBANK timed hold · 20 min EXPERIENCE no hold · book to know reprice risk begins hold released price checked only at commit COMMIT BEFORE FIRST EXPIRY
Each cart item decays on its own clock. The commit window is their intersection.

Once you see the cart this way, the engineering agenda writes itself: track a TTL per item, surface the tightest one to the traveller, and revalidate everything the moment intent turns serious.

The reprice race

Between "add to cart" and "pay" there is always a gap, and in that gap prices move. So revalidate every component immediately before commit: one final availability-and-price check per supplier, in parallel, on a short timeout. A cart that books against the price it fetched during search is not a cart, it is a promise you have delegated to luck.

Three outcomes come back. Each needs a policy decided before launch, not whatever the on-call engineer improvises the first time it happens.

Everything holds. Proceed. This is the common case and it should feel instant.

One leg repriced. You have exactly three moves. Surface the delta: show the new total with the changed component highlighted and require an explicit re-accept. Re-shop: fetch alternatives for the changed component and offer a swap, which is the right move when the original fare class simply sold out. Absorb: eat the difference inside a policy threshold, because interrupting a high-intent checkout over a trivial delta costs more in abandonment than the delta ever will.

The mistake is treating this as an error path. On a volatile route a reprice is a Tuesday. It is business policy, and business policy belongs in configuration where a commercial team can change it, not in a conditional a developer has to redeploy:

{
  "revalidation": {
    "timeout_ms": 4000,
    "on_reprice": {
      "absorb_up_to": {
        "amount": 300,
        "currency": "INR",
        "pct_of_component": 2.0
      },
      "above_threshold": "surface_delta",
      "component_unavailable": "reshop_same_constraints"
    },
    "on_supplier_timeout": "treat_component_as_unverified"
  }
}

A component died. The fare class is gone, the room type sold out. Re-shop within the traveller's original constraints, and whatever you do, do not let the rest of the cart quietly expire while they consider the replacement. Refresh the surviving holds in the background. Losing the second component while someone picks a fix for the first is the most common way a recoverable checkout becomes an abandoned one.

Revalidation does not close the race, it narrows it. The check and the booking are still separate calls and the price can still move between them: you go from a window of minutes to a window of seconds. Only the commit protocol handles those seconds.

The commit is a multi-party saga

Here the ecommerce analogy stops being unhelpful and turns dangerous. When the traveller pays, you must book with three or four suppliers, and there is no transaction coordinator on Earth that spans an airline's ticketing system and a bedbank's reservation database. Two-phase commit needs every participant to expose a prepare phase, hold locks through the coordinator's decision and obey an unlock protocol. Airlines and bedbanks expose none of that. You get "book" and "cancel," with fees, deadlines and moods attached. So the only correctness model available is a saga: a sequence of local transactions, each with a compensating action if a later step fails.

Order of operations is not a detail. It is the design:

  1. Authorise payment first. One authorisation for the full total. No supplier calls until the money is real. An auth is reversible; a booking may not be.
  2. Book the most volatile item first. Usually the flight. It is the component most likely to fail or reprice, and failing early is cheap: nothing to compensate yet. It is also often the hardest to reverse once ticketed, which is exactly why you want it committed while every other component is still safely inside a hold.
  3. Confirm the held items next. The bedbank hold converts to a booking. This should be near-deterministic inside the hold window, which is why it can safely wait its turn.
  4. Book the no-hold items. The experience, the transfer. Highest uncertainty per call, cheapest to compensate.
  5. Capture per confirmation. Convert slices of the auth to captures as each component confirms.

Every supplier attempt carries its own idempotency key, persisted before the call goes out. Supplier APIs time out after the booking exists on their side far more often than anyone admits publicly. Retry without a key and you have a duplicate PNR, a duplicate charge and a reconciliation ticket that outlives the trip. Retry with a key and you get the existing booking back. And when a call times out, the honest state is unknown, not failed. Query the supplier or reconcile out of band before you dare retry or compensate. Guess, and you will eventually cancel a ticket a traveller is sitting on.

Then a later step fails and the saga earns its keep. Say the flight ticketed and the hotel confirm failed hard: the hold lapsed mid-flow, or the bedbank rejected the conversion.

ORCHESTRATOR PAYMENT AIRLINE BEDBANK auth full total · single auth auth ok ticket flight · idem key k1 ticketed · pnr capture flight portion confirm held room · idem key k2 confirm FAILED · hold lapsed COMPENSATION PATH void ticket inside void window · else refund path refund captured flight portion release remaining auth END STATE · order failed cleanly · traveller made whole · every step journalled
Commit saga: the hotel confirm fails after the flight ticketed, so the saga walks backwards.

The compensation path is where policy and engineering meet, and where most teams discover they hold an opinion they never wrote down. Many tickets void free inside a window; miss the window and compensation becomes a refund flow with entirely different economics. Often the right compensation is not reversal at all. Re-shop the hotel and offer the traveller a save, because they wanted the trip, not the refund, and a rescued booking beats a clean rollback every time. Either way the orchestrator journals every step, because a crash between "flight ticketed" and "hotel failed" has to resume into compensation, not amnesia. Partial states are not exceptions to hide. They are states to design, as the order lifecycle essay argues from the servicing side.

The money has its own shape

Charging per component is the wrong shape, and it is what almost every first implementation does because it is the obvious mapping from the cart UI.

One auth, staged captures. The traveller sees one payment. Underneath, authorise the full total once, then capture per supplier confirmation. If the saga aborts, the un-captured remainder releases automatically and only confirmed components were ever charged. Capture-per-confirmation also gives finance a clean join: every capture maps to exactly one supplier reference.

Refunds map back to components. When the hotel cancels three weeks later, the refund must trace to the hotel's captures, not to "the order." This is why the ledger lives at component level from day one: order totals are a view over component entries, never a field you overwrite. The payments and reconciliation essay follows that thread through settlement.

Currency is the quiet one that bites hardest. The airline prices in EUR, the bedbank in USD, the experience in THB, and the traveller pays in INR. Record a rate per component at commit time, because a refund a month later has to reverse at the booked rate and not today's. Then rounding. Each component rounds to the presentment currency's minor unit independently, and the sum of rounded components must equal the charged total exactly. Round the components, compute the residual against the rounded total, assign the remainder deterministically to a designated component. Skip it and your cart is one paisa off, forever, on some fraction of orders, and someone spends a week chasing that paisa across four supplier statements.

What the "just call two APIs" demo never shows

The demo always works. Two API calls, a combined total, a happy path on stage. It dies in the first production week, and by now the reasons are visible. No TTLs, so the fare on screen was stale before the traveller reached the card field. No revalidation, so the first reprice became a support ticket. Sequential booking with no compensation, so the first hotel failure stranded a ticketed flight on the company card. Per-component charging, so the first partial failure double-charged someone. No rounding rule, so finance found it before the customers did.

A real order abstraction owns all of it: per-item hold state and TTLs, a revalidation step with an explicit reprice policy, a journalled saga with ordered booking and compensating actions, idempotency keys on every supplier attempt, single-auth staged-capture payment with component-level ledger entries, and per-component currency and rounding rules. That is the actual product behind the one button.

For a few seconds at commit, every supplier's clock, price and failure mode has to agree. None of them are trying to. Someone has to be accountable for making it happen anyway, and that someone is the order layer, not the checkout screen and never the traveller.