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.
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
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 field | Blinkit / Zepto / BigBasket | Flipkart | Amazon |
|---|---|---|---|
pricePaise | offer price × 100 | final_price | Offers.Listing.Price.Amount |
mrpPaise | MRP field × 100 | mrp | SavingBasis / list price |
inStock / stockQty | hyperlocal stock for the store serving the zone | seller availability | Availability.Type |
etaMinutes | 10–20 min (dark-store ETA) | derived from delivery-date promise | same-day / next-day → minutes |
deliveryFeePaise | zone fee, free over threshold | shipping fee | Prime = 0, else shipping |
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.
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 };
}
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
| Signal | Normalisation (0–1, higher = better) | Default weight |
|---|---|---|
| Effective price (incl. delivery fee) | minLandedPrice / thisLandedPrice | 0.45 |
| Delivery ETA | 1 − clamp(eta / 120min) | 0.30 |
| Stock confidence | in-stock & ample = 1 · low = 0.6 · unknown = 0.4 | 0.15 |
| Source reliability | rolling success + rating, per source & zone | 0.10 |
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]
}
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.
| Source | Price | Delivery | Landed | ETA | Stock | Score | |
|---|---|---|---|---|---|---|---|
| Blinkit | ₹268 | ₹0 | ₹268 | 11 min | ample | 0.97 | Best |
| Zepto | ₹265 | ₹15 | ₹280 | 9 min | ample | 0.94 | |
| BigBasket | ₹262 | ₹0 | ₹262 | 95 min | ample | 0.88 | |
| Amazon | ₹259 | ₹40 | ₹299 | 1 day | ample | 0.61 | |
| Flipkart | — | — | — | — | out | — | excluded |
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
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.
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.
| Concern | Mechanism |
|---|---|
| Deduplicate concurrent identical searches | Request coalescing / single-flight per (sku, zone) key |
| Protect slow / failing sources | Per-source circuit breaker + exponential backoff |
| Respect platform rate limits | Token-bucket per source inside each MCP adaptor (Doc 04) |
| Keep the hot set warm | Background refresh of top-N zone+SKU keys via Kafka triggers |
| Observe correctness | Emit offers_ranked events → win-rate & price-accuracy dashboards (Doc 03 §Analytics) |