Dossier › Document 05
DOCUMENT 05 · PRICE & AVAILABILITY ENGINE

Best price, live stock & fastest ETA

How the backend fans out to five platform MCPs, normalises very different responses into one shape, matches them to a canonical SKU, and ranks them into the single "best buy" a shopper sees — with the algorithm, the caching strategy and the Node.js code.

5→1
Sources ranked to one pick
1.2s
Fan-out deadline
30–60s
Offer cache TTL
4
Ranking signals

This is the heart of QuickCart: turning five inconsistent, hyperlocal, constantly-changing price lists into one trustworthy answer — fast enough to feel instant and fresh enough to trust at checkout. It builds on the MCP adaptor layer (Doc 04) and the catalogue in Doc 03.

The problem in one line

A shopper types butter. Five platforms answer with five different product IDs, price shapes, pack sizes, stock signals and delivery promises — some in rupees, some in paise, some quoting MRP, some the offer price, some silent on stock. The engine must reconcile all of it into a ranked list in under 1.5 seconds, and re-verify the winner before money moves.

🧭

Gather

Scatter-gather the query to all five MCPs in parallel, under a hard deadline. Slow sources are dropped, not waited on.

🧮

Normalise & match

Coerce every response into one Offer shape and attach it to a canonical SKU via the source-map.

🏆

Rank

Score each offer on price, ETA, stock and source reliability; return the sorted list and the winner.

Aggregation pipeline

Every search or product-detail view runs the same pipeline. It is cache-first: a warm zone+SKU key returns in single-digit milliseconds; a miss triggers the fan-out below.

flowchart LR REQ["Request:
query + zone"] --> C{"Redis cache
hit?"} C -->|"hit & fresh"| OUT["Ranked offers[]"] C -->|"miss / stale"| RES["Resolve query →
canonical SKU(s)"] RES --> FAN{{"Scatter-gather
to 5 MCPs"}} FAN --> B["Blinkit"] FAN --> Z["Zepto"] FAN --> G["BigBasket"] FAN --> F["Flipkart"] FAN --> A["Amazon"] B & Z & G & F & A --> NORM["Normalise →
Offer shape"] NORM --> MATCH["Attach to
canonical SKU"] MATCH --> RANK["Score & rank"] RANK --> STORE[("Cache
TTL 30–60s")] STORE --> OUT
Figure 5.1 — Cache-first aggregation. A miss fans out to five MCPs, each result is normalised, matched and ranked, then cached with a short TTL.
Why cache-first with a short TTL Quick-commerce price and stock move fast, so TTLs are short (30s for price/stock, up to 60s for ETA). But identical searches cluster hard (everyone wants milk at 8am), so even a 30s cache slashes MCP call volume and cost while keeping data effectively live. Stale-while-revalidate serves the cached value instantly and refreshes in the background.

Normalising five different responses

Each adaptor already maps its platform onto the canonical tool contract (Doc 04), but the engine still normalises the values: money to integer paise, MRP vs effective price after offers, pack size to a comparable unit, and stock to a single signal. This makes offers directly comparable.

The canonical Offer

types/offer.ts
export interface Offer {
  source:        SourceId;        // "blinkit" | "zepto" | "bigbasket" | "flipkart" | "amazon"
  productId:     string;          // canonical QuickCart SKU
  sourceProductId: string;
  mrpPaise:      number;          // list / maximum retail price
  pricePaise:    number;          // effective price after platform offers
  unitPricePaise:number;          // per-100g / per-unit, for like-for-like compare
  inStock:       boolean;
  stockQty:      number | null;   // null = unknown but purchasable
  etaMinutes:    number | null;   // delivery estimate for this zone
  deliveryFeePaise: number;
  rating:        number | null;
  fulfilment:    "managed" | "handoff";  // see Doc 03 §Payments
  fetchedAt:     string;          // ISO timestamp — drives freshness
  deepLink:      string;          // for assisted hand-off checkout
}

Field mapping across sources

Canonical fieldBlinkit / Zepto / BigBasketFlipkartAmazon
pricePaiseoffer price × 100final_priceOffers.Listing.Price.Amount
mrpPaiseMRP field × 100mrpSavingBasis / list price
inStock / stockQtyhyperlocal stock for the store serving the zoneseller availabilityAvailability.Type
etaMinutes10–20 min (dark-store ETA)derived from delivery-date promisesame-day / next-day → minutes
deliveryFeePaisezone fee, free over thresholdshipping feePrime = 0, else shipping
MRP is a claim, not a constant Different sellers list slightly different MRPs for the same GTIN. QuickCart shows the source's own MRP alongside its effective price so "you save" is always internally consistent, and flags implausible MRPs (e.g. price > MRP) rather than displaying a negative discount.

Scatter-gather with a deadline

The fan-out uses Promise.allSettled wrapped in a per-source timeout so one slow platform can never hold up the response. Failed or timed-out sources are simply omitted and the result is labelled "showing N of 5 sources." Circuit breakers trip a repeatedly-failing source out of rotation.

services/aggregation/gather.ts
import { mcp } from "./mcpClient";
import { normalise } from "./normalise";
import { SOURCES, DEADLINE_MS } from "./config";

// Wrap any promise so it rejects after `ms` instead of hanging the fan-out.
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
  return Promise.race([
    p,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error("deadline_exceeded")), ms)),
  ]);
}

// Fan out one query to every source; return only the offers that came back in time.
export async function gatherOffers(sku: string, zone: Zone): Promise<GatherResult> {
  const settled = await Promise.allSettled(
    SOURCES.map(async (source) => {
      if (breaker.isOpen(source)) throw new Error("circuit_open");
      const raw = await withTimeout(
        mcp.call(source, "get_offer", { sourceSku: map(sku, source), zone }),
        DEADLINE_MS,                       // 1200 ms
      );
      return normalise(source, raw);   // → Offer
    }),
  );

  const offers  = settled.filter(isFulfilled).map((s) => s.value);
  const misses  = settled.filter(isRejected);
  misses.forEach((m, i) => breaker.record(SOURCES[i], m.reason));

  return { offers, sourcesQueried: SOURCES.length, sourcesReturned: offers.length };
}
Partial results are first-class Returning 4 of 5 offers in 1.2s beats returning 5 in 6s. The UI shows a "4 of 5 sources" badge and the missing source refreshes asynchronously into the cache, so the next shopper sees the full set.

The ranking algorithm

"Best" is not just "cheapest." QuickCart scores each in-stock offer on four normalised signals and sorts by the weighted total. Weights are tunable per surface — a "Fastest" toggle raises the ETA weight; a "Cheapest" toggle raises the price weight (these are the filter chips in Doc 01).

Signals & default weights

SignalNormalisation (0–1, higher = better)Default weight
Effective price (incl. delivery fee)minLandedPrice / thisLandedPrice0.45
Delivery ETA1 − clamp(eta / 120min)0.30
Stock confidencein-stock & ample = 1 · low = 0.6 · unknown = 0.40.15
Source reliabilityrolling success + rating, per source & zone0.10
services/aggregation/rank.ts
const W = { price: 0.45, eta: 0.30, stock: 0.15, reliability: 0.10 };

const landed  = (o: Offer) => o.pricePaise + o.deliveryFeePaise;
const stockScore = (o: Offer) =>
  !o.inStock ? 0 : o.stockQty == null ? 0.4 : o.stockQty > 5 ? 1 : 0.6;

export function rankOffers(offers: Offer[], w = W): Ranked[] {
  const live = offers.filter((o) => o.inStock);
  if (!live.length) return [];

  const minLanded = Math.min(...live.map(landed));

  const scored = live.map((o) => {
    const priceScore = minLanded / landed(o);                 // 1 = cheapest
    const etaScore   = 1 - Math.min((o.etaMinutes ?? 120) / 120, 1);
    const score =
      w.price * priceScore +
      w.eta   * etaScore +
      w.stock * stockScore(o) +
      w.reliability * reliability(o.source, o.zone);
    return { ...o, score, priceScore, etaScore };
  });

  return scored.sort((a, b) => b.score - a.score);       // winner = [0]
}
Tie-breaks & guard-rails Equal scores break toward faster ETA, then higher reliability. An offer whose fetchedAt is older than its TTL is refreshed before it can win. The winner is re-validated at checkout (§7) so the shopper is never charged a stale price.

Worked example — "Amul Butter 500g"

Five offers come back for one canonical SKU. Landed price = price + delivery fee; the winner maximises the weighted score, not merely the lowest sticker price.

SourcePriceDeliveryLandedETAStockScore
Blinkit₹268₹0₹26811 minample0.97Best
Zepto₹265₹15₹2809 minample0.94
BigBasket₹262₹0₹26295 minample0.88
Amazon₹259₹40₹2991 dayample0.61
Flipkartoutexcluded

Blinkit wins despite not being the cheapest sticker price: zero delivery fee makes its landed price lowest, and an 11-minute ETA scores far higher than BigBasket's 95 minutes. Values illustrative.

What the API returns

GET /v1/products/QC-GRO-AMUL-BUTTER-500G/offers → 200
{
  "productId": "QC-GRO-AMUL-BUTTER-500G",
  "sourcesQueried": 5,
  "sourcesReturned": 4,
  "best": "blinkit",
  "offers": [
    { "source":"blinkit", "pricePaise":26800, "mrpPaise":29500,
      "deliveryFeePaise":0, "etaMinutes":11, "inStock":true,
      "fulfilment":"managed", "score":0.97, "fetchedAt":"2026-09-08T12:00:05Z" },
    { "source":"zepto", "pricePaise":26500, "deliveryFeePaise":1500,
      "etaMinutes":9, "inStock":true, "score":0.94 }
    /* …bigbasket, amazon… */
  ]
}

This is the exact array the Detail screen (Doc 01) renders as the "Compare 5 sources" block, and whose best drives the source label on every list card.

Freshness & checkout re-validation

Speed and freshness pull in opposite directions; QuickCart resolves the tension by tiering data by volatility and by always re-checking the winner before payment.

Price & stock

TTL 30s, stale-while-revalidate. Served from Redis; background refresh keeps the hot set current.

Delivery ETA

TTL 60s per zone — changes slower than price but is still hyperlocal.

Catalogue & images

TTL hours. Stable canonical data; invalidated on catalogue edits via Kafka.

Re-validate before charging

sequenceDiagram autonumber participant App as 📱 App participant Agg as Aggregation participant MCP as Winning source MCP App->>Agg: checkout(productId, chosenSource) Agg->>MCP: get_offer (live, bypass cache) MCP-->>Agg: current price + stock alt price/stock unchanged Agg-->>App: OK — proceed to pay else moved within tolerance Agg-->>App: confirm new price (1-tap accept) else out of stock / big jump Agg-->>App: re-rank — suggest next-best source end
Figure 5.2 — The winning offer is re-fetched live at checkout; the shopper is never charged a cached price that has since moved.

Where the latency goes

The 1.5s p95 budget for a cold search, broken down. The fan-out dominates — which is exactly why it runs in parallel under a deadline rather than sequentially.

Cache lookup MCP fan-out (parallel) Normalise + match Rank + serialise
0 300ms 600ms 900ms 1200ms fan-out ≈ 950ms (parallel, capped at 1200ms)
Figure 5.3 — Cold-search latency budget (illustrative). Warm cache hits skip everything but the ~20ms lookup.

How it lives in the backend

The engine is the Aggregation service (the mcp-adaptor of Doc 03). It is stateless and horizontally scalable — all shared state lives in Redis — so it scales with search traffic.

ConcernMechanism
Deduplicate concurrent identical searchesRequest coalescing / single-flight per (sku, zone) key
Protect slow / failing sourcesPer-source circuit breaker + exponential backoff
Respect platform rate limitsToken-bucket per source inside each MCP adaptor (Doc 04)
Keep the hot set warmBackground refresh of top-N zone+SKU keys via Kafka triggers
Observe correctnessEmit offers_ranked events → win-rate & price-accuracy dashboards (Doc 03 §Analytics)
Read alongside Doc 04 defines the get_offer tool and per-platform limits this engine calls; Doc 03 shows where the Aggregation service sits; Doc 02 shows the same fan-out as a user-facing sequence.