Quick-commerce delivery SLA by dark store: percentiles, not averages
In this article (5 sections)
Related programme: the Data Analytics with Generative AI course (3–4 months; ₹90,000 (inclusive of GST) · EMI from ₹4,000/month).
Measure a dark store's delivery SLA as the share of delivered orders that arrived within the minutes promised to that customer at checkout, reported with the median and 90th-percentile delivery time. Then split each order's time into pick-and-pack, rider wait and travel, because two stores with the same lateness can need completely different fixes: more riders at peak for one, a smaller promise for distant addresses at the other.
Check the data before you time anything
Quick-commerce timestamps come from at least three clocks: the customer app, the store's picking terminal and the rider's phone. Before calculating any SLA, confirm:
- 1Every order has a final status, and cancelled orders are separated rather than timed.
- 2The promised minutes were stored at checkout. Recomputing the promise later from today's rules gives the wrong target.
- 3Stage timestamps are in order: placed, packed, picked up by the rider, delivered. A rider pickup stamped before packing means a device clock is off.
- 4All timestamps are in one time zone.
The orders below are synthetic, from three dark stores on one evening between 17:00 and 22:00. DS-Central was given a rider shortage from 19:00 to 21:00. DS-East serves a wider radius. One rider's phone runs slow and stamps two pickups before the bags were packed.
-- Synthetic orders from three dark stores on one evening (not real platform data)
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY, store TEXT, placed_at TEXT, promised_min INTEGER,
packed_at TEXT, rider_picked_at TEXT, delivered_at TEXT, status TEXT);
WITH RECURSIVE k(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM k WHERE i<600),
base AS (
SELECT i,
CASE i % 3 WHEN 0 THEN 'DS-North' WHEN 1 THEN 'DS-Central' ELSE 'DS-East' END AS store,
datetime('2026-09-26 17:00', '+' || ((i * 17) % 300) || ' minutes') AS placed_at,
CASE WHEN (i * 7) % 10 < 6 THEN 10 ELSE 15 END AS promised_min,
2 + ((i / 3) * 11) % 3 AS pick_min,
(i * 13) % 7 AS travel_extra
FROM k),
timed AS (
SELECT *,
-- DS-Central runs short of riders between 19:00 and 21:00
CASE WHEN store = 'DS-Central' AND time(placed_at) BETWEEN '19:00' AND '21:00'
THEN 3 + (i * 5) % 8 ELSE ((i / 3) * 5) % 3 END AS wait_min,
-- DS-East serves a wider radius
CASE WHEN store = 'DS-East' THEN 5 ELSE 2 END + travel_extra * promised_min / 15 AS travel_min
FROM base)
INSERT INTO orders
SELECT i, store, placed_at, promised_min,
datetime(placed_at, '+' || pick_min || ' minutes'),
datetime(placed_at, '+' || (pick_min + wait_min) || ' minutes'),
CASE WHEN i % 40 = 0 THEN NULL
ELSE datetime(placed_at, '+' || (pick_min + wait_min + travel_min) || ' minutes') END,
CASE WHEN i % 40 = 0 THEN 'cancelled' ELSE 'delivered' END
FROM timed;
-- one rider phone with a clock 4 minutes slow stamps two pickups before packing
UPDATE orders SET rider_picked_at = datetime(rider_picked_at, '-4 minutes') WHERE order_id IN (102, 201);Build durations in a view and run the checks first:
-- julianday differences carry float noise: round to hundredths of a minute
CREATE VIEW d AS
SELECT order_id, store, promised_min, status,
ROUND((julianday(delivered_at) - julianday(placed_at)) * 1440, 2) AS total_min,
ROUND((julianday(packed_at) - julianday(placed_at)) * 1440, 2) AS pick_min,
ROUND((julianday(rider_picked_at) - julianday(packed_at)) * 1440, 2) AS wait_min,
ROUND((julianday(delivered_at) - julianday(rider_picked_at)) * 1440, 2) AS travel_min
FROM orders;
-- data checks first
SELECT status, COUNT(*) AS orders,
SUM(wait_min < 0) AS negative_rider_wait
FROM d GROUP BY status;The check returns 15 cancelled orders and 585 delivered, two of which show a negative rider wait of about two minutes. Those two are the skewed phone. Clamp the stage to zero for stage analysis, as a later query does, and send the device ID to rider operations. Do not drop the orders: their total time, measured from the customer's order and the delivery stamp, is still usable.
The rounding in the view matters too. Differences of julianday values are floating-point, and an order that took exactly 10 minutes can come out as 10.0000005, which fails a <= 10 test. Round durations before comparing them with a promise.
P50, P90 and the promise, per store
SQLite has no percentile function, so rank the delivered orders within each store and pick the row at the percentile position:
WITH ranked AS (
SELECT store, total_min, promised_min,
ROW_NUMBER() OVER (PARTITION BY store ORDER BY total_min) AS rn,
COUNT(*) OVER (PARTITION BY store) AS n
FROM d WHERE status = 'delivered')
SELECT store, n AS delivered,
MAX(CASE WHEN rn = CAST(0.5 * n + 0.999999 AS INTEGER) THEN total_min END) AS p50_min,
MAX(CASE WHEN rn = CAST(0.9 * n + 0.999999 AS INTEGER) THEN total_min END) AS p90_min,
ROUND(AVG(total_min), 1) AS mean_min,
ROUND(100.0 * AVG(total_min <= promised_min), 1) AS on_promise_pct,
ROUND(100.0 * AVG(total_min <= 10), 1) AS within_10_min_pct
FROM ranked GROUP BY store ORDER BY on_promise_pct;| Store | Delivered | P50 (min) | P90 (min) | Mean (min) | Within own promise % | Within 10 min % |
|---|---|---|---|---|---|---|
| DS-East | 195 | 11.0 | 15.0 | 11.3 | 61.5 | 35.9 |
| DS-Central | 195 | 10.0 | 16.0 | 10.5 | 71.8 | 54.4 |
| DS-North | 195 | 8.0 | 12.0 | 8.2 | 94.4 | 83.1 |
The mean puts DS-Central and DS-East within a minute of each other. The P90 puts DS-Central last, and the promise-based rate puts DS-East last. Neither ordering is visible in the average. The "within 10 min" column shows the cost of one fixed threshold: it calls DS-East's 15-minute promises late even when they arrived on time.
Split the minutes into stages
-- where the late minutes go: stage medians for late versus on-time orders
WITH s AS (
SELECT store, pick_min, MAX(wait_min, 0) AS wait_min, travel_min,
CASE WHEN total_min <= promised_min THEN 'on time' ELSE 'late' END AS outcome
FROM d WHERE status = 'delivered')
SELECT store, outcome, COUNT(*) AS orders,
ROUND(AVG(pick_min), 1) AS avg_pick, ROUND(AVG(wait_min), 1) AS avg_rider_wait,
ROUND(AVG(travel_min), 1) AS avg_travel
FROM s GROUP BY store, outcome ORDER BY store, outcome;
-- on-promise rate by the promise made
SELECT store, promised_min, COUNT(*) AS delivered,
ROUND(100.0 * AVG(total_min <= promised_min), 1) AS on_promise_pct
FROM d WHERE status = 'delivered' GROUP BY store, promised_min ORDER BY store, promised_min;
-- DS-Central by hour of order
SELECT strftime('%H:00', o.placed_at) AS hour, COUNT(*) AS delivered,
ROUND(100.0 * AVG(d.total_min <= d.promised_min), 1) AS on_promise_pct,
ROUND(AVG(MAX(d.wait_min, 0)), 1) AS avg_rider_wait
FROM d JOIN orders o USING (order_id)
WHERE d.store = 'DS-Central' AND d.status = 'delivered'
GROUP BY hour ORDER BY hour;Stage averages, late versus on-time orders:
| Store | Outcome | Orders | Pick (min) | Rider wait (min) | Travel (min) |
|---|---|---|---|---|---|
| DS-Central | late | 55 | 3.2 | 6.7 | 4.6 |
| DS-Central | on time | 140 | 2.9 | 1.9 | 4.1 |
| DS-East | late | 75 | 3.5 | 1.5 | 7.6 |
| DS-East | on time | 120 | 2.7 | 0.7 | 7.1 |
| DS-North | late | 11 | 4.0 | 2.0 | 5.5 |
| DS-North | on time | 184 | 2.9 | 0.9 | 4.2 |
At DS-Central, late orders waited 6.7 minutes for a rider against 1.9 minutes for on-time orders. By hour, its on-promise rate is 97.4% at 17:00 and 89.7% at 18:00, falls to 43.6% at 19:00 and 35.9% at 20:00, and recovers to 92.3% at 21:00. Average rider wait in the two bad hours is 6.6 minutes.
At DS-East, rider wait barely differs between late and on-time orders; travel is long for everyone. The promise breakdown explains it: 10-minute promises were met 40.9% of the time and 15-minute promises 91.3%. The store is promising 10 minutes to addresses it cannot reach in 10.
Two stores, two decisions
| Store | Where the minutes go | Decision for the city operations team |
|---|---|---|
| DS-Central | rider wait, 19:00 to 21:00 | add riders or batch fewer orders per rider in that window; recheck the hourly table next week |
| DS-East | travel on 10-minute promises | promise 15 minutes to the outer delivery ring, or shrink the 10-minute zone |
| DS-North | nothing material | leave alone; use its pick times as the benchmark |
Things that quietly distort a dark-store SLA
- Cancelled orders disappearing. An order cancelled because the customer gave up waiting is a delivery failure, not a missing row. Report cancellations with their reason next to the on-promise rate.
- Delivered-at stamped late. Some riders mark delivery after reaching the next stop. Compare the delivery stamp with the phone's location trace on a sample.
- Promise changed during surge. If the app extends promises in bad weather, the on-promise rate holds up while P90 rises. Show both.
- Percentiles on tiny groups. DS-North has only 11 late orders; any statistic about them moves with one order. Keep counts beside every percentile.
The city operations manager needs one line per store each morning: on-promise rate, P90, cancellations and the worst hour, with the stage that caused it. Window functions, percentile ranking and stage timing are practised on data like this in the Data Analyst programme. For the returns side of online retail, see e-commerce RTO and COD risk.
Frequently asked questions
How should a dark store's delivery SLA be measured?
Compare each delivered order's actual minutes from placement to delivery with the minutes promised to that customer at checkout, and report the share delivered within promise alongside the 90th percentile of delivery time. Averages hide the late tail that customers remember, and a single fixed threshold ignores that different orders were promised different times.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Analytics with Generative AI programme — 3–4 months. The full analyst stack — Excel, SQL, Power BI and Python pipelines — then a generative-AI layer you can prove is right.
Explore Data Analytics with Generative AI