DEV Community

Anakin
Anakin

Posted on

Dynamic Pricing Is a Data Freshness Problem Before It Is a Model Problem

If you have ever worked on marketplace pricing, the obvious problem is usually not the hardest one. The obvious problem is deciding the price. The harder problem is deciding whether the data you are using is fresh, complete, and safe enough to price from at all.

Food delivery makes this visible because the system changes every few minutes. Demand spikes in one neighborhood. Couriers move to another. A restaurant gets backed up. Rain changes ETAs. A competitor starts a promo. If your pricing service reacts to one signal but misses the others, users see weird fees, drivers get bad incentives, and support gets the tickets.

Price is an output of marketplace state

A delivery fee is not just a margin calculation. It is a control surface for the marketplace.

At minimum, a pricing service needs inputs like:

  • current order demand by zone
  • available couriers by zone
  • restaurant prep time estimates
  • recent cancellation and late delivery rates
  • traffic or ETA predictions
  • customer segment or subscription status
  • promo budget and experiment assignment
  • competitor price or promo observations, if you track them

The important detail is that these inputs age differently. Courier availability might be useless after 30 seconds. Restaurant prep time might be acceptable for 5 minutes. Customer segment can be cached for hours.

Treating all signals as equally fresh is how pricing systems make bad decisions confidently.

Put guardrails before the model

You can use a regression model, a rules engine, a bandit, or a hand-tuned formula. The pattern still needs the same safety checks around it.

Here is a simplified TypeScript version of a delivery-fee quote function:

type Signals = {
  zoneId: string;
  demandLast5Min: number;
  availableCouriers: number;
  avgPrepTimeMin: number;
  etaP50Min: number;
  updatedAtMs: number;
};

type Quote = {
  feeCents: number;
  reason: "NORMAL" | "SURGE" | "STALE_SIGNALS" | "CAPPED";
};

const MAX_SIGNAL_AGE_MS = 90_000;
const MAX_FEE_CHANGE_PCT = 0.25;

export function quoteDeliveryFee(
  baseFeeCents: number,
  previousFeeCents: number,
  signals: Signals,
  nowMs = Date.now()
): Quote {
  const ageMs = nowMs - signals.updatedAtMs;

  if (ageMs > MAX_SIGNAL_AGE_MS) {
    return {
      feeCents: previousFeeCents,
      reason: "STALE_SIGNALS"
    };
  }

  const courierCount = Math.max(signals.availableCouriers, 1);
  const pressure = signals.demandLast5Min / courierCount;

  let multiplier = 1;

  if (pressure > 4) multiplier += 0.35;
  else if (pressure > 2) multiplier += 0.15;

  if (signals.avgPrepTimeMin > 25) multiplier += 0.10;
  if (signals.etaP50Min > 40) multiplier += 0.10;

  const rawFee = Math.round(baseFeeCents * multiplier);

  const maxAllowed = Math.round(previousFeeCents * (1 + MAX_FEE_CHANGE_PCT));
  const cappedFee = Math.min(rawFee, maxAllowed);

  return {
    feeCents: cappedFee,
    reason: cappedFee !== rawFee ? "CAPPED" : multiplier > 1 ? "SURGE" : "NORMAL"
  };
}
Enter fullscreen mode Exit fullscreen mode

This is not a pricing strategy. It is a safety shape.

The function does three things that matter in production:

  1. It refuses to price from stale operational data.
  2. It avoids division by zero when courier supply is empty.
  3. It caps sudden fee changes so users do not see a fee jump from $2 to $9 in one refresh.

The division-by-zero case is not theoretical. In JavaScript, this can turn into a bad downstream payload:

JSON.stringify({ pressure: Infinity });
// {"pressure":null}
Enter fullscreen mode Exit fullscreen mode

If another service treats null as 0, an outage in courier availability can become free delivery during peak demand. That is the kind of bug that looks like a finance problem until someone traces it back to serialization.

Discounts should behave like experiments

A discount is not useful because conversion went up. Conversion almost always goes up when you make something cheaper.

The question is whether the discount changed future behavior enough to justify the cost. Did the customer order again without a coupon? Did average order value increase? Did the promo mostly attract users who only buy when subsidized?

That means discounts need experiment IDs, holdouts, and post-promo measurement. A basic query might look like this:

select
  experiment_id,
  variant,
  count(distinct user_id) as users,
  avg(order_total_cents) as avg_order_value,
  avg(discount_cents) as avg_discount,
  sum(case when reordered_within_14d then 1 else 0 end)::float
    / count(*) as reorder_rate_14d
from promo_orders
where created_at >= now() - interval '30 days'
group by experiment_id, variant;
Enter fullscreen mode Exit fullscreen mode

If treatment users reorder at the same rate as holdout users but cost $4 more per order, the discount did not create durable demand. It rented it.

External signals need timestamps and confidence

Competitor prices, restaurant menu changes, delivery estimates, and visible promos can all be useful inputs. They can also poison a pricing system if you treat scraped or third-party data as truth without metadata.

Every external observation should carry at least:

  • observed_at
  • source
  • confidence
  • entity_id
  • raw_value
  • normalized_value
  • parser_version

If a competitor promo page changes structure and your parser starts reading the wrong value, the worst outcome is not a failed job. The worst outcome is a successful job that writes believable garbage.

For teams that use external food delivery pages as pricing inputs, Wire is useful when you treat extracted prices, ETAs, and promos as timestamped observations with failure handling instead of silent facts.

The same rule applies if you build the extraction yourself. Store raw responses when possible, version your parsers, and alert on distribution shifts. If yesterday’s delivery fees ranged from 99 to 799 cents and today’s are all exactly 0, do not feed that into pricing.

Surge pricing is an operations mechanism

People often describe surge pricing as a way to increase revenue. In delivery marketplaces, it also coordinates supply.

A higher fee can reduce low-value demand. A higher courier payout can pull more drivers into a zone. But if you raise the customer fee without changing courier incentives, you may just reduce orders while leaving late deliveries unsolved.

That is why the pricing decision should emit more than a number. It should emit an explanation and an event:

{
  "quote_id": "q_123",
  "zone_id": "downtown",
  "fee_cents": 349,
  "reason": "SURGE",
  "pressure": 4.8,
  "signals_updated_at": "2026-08-18T12:04:30Z"
}
Enter fullscreen mode Exit fullscreen mode

Operations, finance, support, and experimentation systems can all use that event. Without it, you only know what price the user saw. You do not know why they saw it.

What to build first

Do not start by making the pricing formula more complex. Start by measuring whether your current pricing inputs can be trusted.

Pick one pricing decision and add:

  • freshness checks per signal
  • caps on sudden price movement
  • explicit fallback behavior
  • experiment IDs for discounts
  • reason codes on every quote
  • alerts for impossible or suspicious input distributions

Once those pieces exist, improving the model becomes much safer. Without them, a smarter model just makes bad decisions faster.

Top comments (0)