Grocery Delivery App Development
Build custom app solutions with Scrums.com's expert development team. With an NPS (Net Promoter Score) of 82, Scrums.com crafts cost-effective, custom applications that drive results.
Companies building grocery delivery platforms, dark store operations software, and multi-retailer grocery marketplaces need engineering teams who understand the specific data challenges of perishable inventory, variable slot capacity, and the substitution workflows that separate grocery from standard e-commerce. Scrums.com provides dedicated software engineering teams for grocery delivery platform development, deploying production-ready systems with real-time inventory sync, slot-based scheduling infrastructure, and the event-driven order state machines that grocery operations require.
Product Catalogue, Inventory Sync, and Order Intake Architecture
Grocery platforms manage a significantly more complex product model than standard e-commerce: perishable goods with short expiry windows, weight-variable products sold by approximation (meat, produce), and inventory that changes by the hour across multiple fulfilment locations.
The product catalogue uses a products table (sku, name, category_id, unit_type [EACH | WEIGHT_KG | WEIGHT_G | VOLUME_L]) paired with a product_pricing table with store_id, effective_from/effective_to, and price_per_unit. For weight-variable products, price_per_unit is per kg/g and a tare_weight adjustment is stored in product_weight_config. Prices are versioned: the current price for a store/product combination is always the row with effective_from <= NOW() AND (effective_to IS NULL OR effective_to > NOW()).
Store inventory is managed through a store_inventory table: store_id, product_id, quantity_on_hand, last_updated_at, and restock_threshold. Inventory movements write to a store_inventory_transactions table: RECEIVED | PICKED | WASTED | ADJUSTMENT: never a direct update to quantity_on_hand, which is a materialised view over transactions. Any inventory discrepancy is therefore traceable to a specific transaction.
For real-time inventory sync from warehouse management or POS systems, an inventory_sync_log records each inbound update: source_system_id, raw_payload, ingested_at, and processing_status (RECEIVED / PROCESSED | FAILED). The ingest handler is idempotent on (source_system_id, source_reference_id). Products with quantity_on_hand = 0 or below restock_threshold generate an availability_status flag consumed by the catalogue API: out-of-stock products are excluded from new order item creation automatically.
Order intake uses the same idempotent endpoint pattern: client_idempotency_key with UNIQUE constraint prevents duplicate orders from retries. The order state machine: CART / PLACED / PAYMENT_PROCESSING / CONFIRMED / PICKING / PACKED / OUT_FOR_DELIVERY / DELIVERED | FAILED | CANCELLED. Each transition writes an order_events row.
Slot-Based Delivery Scheduling, Order Batching, and Picker Workflow
Grocery delivery schedules on finite capacity: each delivery slot has a maximum number of orders, and slot availability must be enforced without race conditions.
Slots are stored in a delivery_slots table: store_id, slot_date, slot_start_time, slot_end_time, max_orders, and a zone_id (delivery postcodes/areas served by this slot). Slot bookings write to slot_bookings: slot_id, order_id, booked_at. A slot_availability view computes remaining capacity as max_orders minus COUNT(slot_bookings) per slot. Slot booking uses SELECT ... FOR UPDATE on the slot row to prevent overbooking under concurrent requests. Slot capacity can be adjusted by writing a new slot_capacity_overrides row: no schema change required.
Order batching groups orders by slot and picker zone. The batch_assignment_engine queries confirmed orders for a slot, groups them by zone, and writes pick_batches rows: batch_id, slot_id, zone_id, assigned_picker_id, and a status machine (CREATED / ASSIGNED / IN_PROGRESS / COMPLETED). Each batch has child pick_tasks rows: batch_id, product_id, quantity_required, aisle_location. Aisle locations come from a product_locations config table per store, driving the optimal pick sequence to minimise walk distance.
The picker app reads from pick_tasks ordered by aisle sequence. Each pick action writes a pick_events row: task_id, picker_id, action_type (PICKED | SHORT_PICK | SUBSTITUTED | SKIPPED), quantity_actual, picked_at. SHORT_PICK fires when quantity_actual is less than quantity_required. The substitution workflow then runs. On pack completion, a packing_events row is written and the order transitions to PACKED.
Grocery delivery apps like these are built and delivered by dedicated engineering teams through our mobile app development service.
Substitution Logic, Out-of-Stock Management, and Customer Communication
Substitution is one of the most operationally complex parts of grocery: a substitution the customer dislikes is more damaging than a cancellation. The substitution system must be fast for pickers, configurable by store, and auditable.
The substitution_rules table stores per-product preferences at three levels: CUSTOMER (customer's own saved preferences), STORE (retailer-defined defaults), GLOBAL (category-level fallback). Resolution order is CUSTOMER > STORE > GLOBAL. For each short-picked item, the substitution engine queries the three levels in order and proposes a substitute from the first matching rule. The proposed substitute's availability is checked in real time against store_inventory before presentation.
Customer substitution preferences are stored in customer_substitution_preferences: customer_id, product_id, preference_type (ALWAYS_SUBSTITUTE_WITH | NEVER_SUBSTITUTE | ACCEPT_STORE_CHOICE). Product-level preferences take precedence over category-level preferences.
When a SHORT_PICK fires, the substitution_engine_events table records the proposal: original_product_id, proposed_substitute_id, rule_source, and the picker's decision (ACCEPTED | DECLINED | ALTERNATIVE_CHOSEN). If the customer has configured do-not-substitute, the item is removed and a removal_events row is written. Customer notifications fire via notification_rules config: the customer receives a push or SMS with the substitute proposal and response options (accept/decline/choose_alternative via deep link). Customer responses write back to substitution_responses; the picker sees the response in real time.
Weight capture for variable-weight products: at pick time, the picker enters actual_weight_kg. The order_line_items table stores both estimated_quantity (from order) and actual_quantity (from pick). The final charge is computed from actual_quantity, not the estimate. Weight variance above weight_variance_threshold_pct (config per product category) triggers a customer notification before final charge is applied.
Payment Processing, Promotions, and Grocery Analytics
Grocery payments carry two complexity points that standard e-commerce doesn't: pre-authorisation holds (because the final amount is unknown until picking completes for weight-variable products) and slot-tied refund logic (cancellations before picking vs after picking have different refund policies).
At order placement, a pre-authorisation is placed for estimated_order_total + weight_variance_buffer (configurable percentage, typically 10-15%). The pre_authorisation amount is stored in payment_preauth records. On pack completion, the final charge is computed from actual_quantity picks and the payment is captured at the final_amount. If final_amount is less than preauth_amount, the difference is released via capture_release_events. All payment state changes write to a payment_events child table: the payments table is never directly updated.
Promotions extend the standard promo model with grocery-specific fields: applies_to_category (JSONB array of category IDs for category promotions), requires_loyalty_tier (for tiered loyalty discounts), and product_bundle_rules (JSONB for multi-buy promotions like 3-for-2). Promo redemptions use SELECT ... FOR UPDATE on the max_uses check to prevent overselling under concurrent requests.
Loyalty programmes use a loyalty_ledger: customer_id, event_type (EARNED | REDEEMED | EXPIRED | ADJUSTED), points_delta, reference_order_id, created_at. Current balance = SUM(points_delta) from the ledger: no mutable balance counter. Points per order are computed from a loyalty_earn_rules config table with per-category multipliers and minimum spend thresholds.
Analytics materialised views: orders_by_store_by_hour (slot utilisation heatmap), substitution_rate_by_product (most-substituted SKUs), picker_productivity_kpis (picks per hour, short-pick rate), delivery_kpis (on-time rate, return rate, average delivery time). These feed the operations dashboard without touching live transactional tables.
Frequently Asked Questions
How do you handle inventory sync across multiple store locations in real time?
Each inventory sync inbound event writes to an inventory_sync_log with an idempotency constraint on (source_system_id, source_reference_id). Quantity on hand is a materialised view over a store_inventory_transactions ledger: never a mutable field. Any discrepancy between physical count and system count is traceable to a specific transaction row.
How does your substitution engine work for short-picked items?
The substitution_rules table has three tiers: CUSTOMER preferences, STORE defaults, GLOBAL category fallbacks, resolved in that priority order. The proposed substitute's availability is checked in real time before presentation to the picker. The customer receives a notification with response options; the picker sees the response in real time on the pick app.
How do you prevent slot overbooking under concurrent requests?
Slot booking uses SELECT ... FOR UPDATE on the delivery_slots row. This locks the slot for the duration of the transaction, so concurrent booking attempts queue rather than race. Slot capacity is a configurable field: overrides write a new slot_capacity_overrides row without schema changes.
How are weight-variable product charges handled?
A pre-authorisation is placed at order time for the estimated total plus a configurable weight variance buffer. At pack completion, the final charge is computed from actual picked weights. Variance above a per-category threshold triggers a customer notification before the final charge is captured. The full preauth and capture history is preserved in payment_events.
How long does it take to deploy a grocery delivery platform with Scrums.com?
Scrums.com deploys dedicated engineering teams within 21 days. The platform ships with inventory sync infrastructure, slot scheduling, idempotent order intake, substitution engine scaffolding, and payment pre-authorisation architecture ready for configuration.
Don't Just Take Our Word for It
Hear from some of our amazing customers who are building with Scrums.com Teams.
Find Related App Types
Omnichannel Retail App
Budgeting App
Food Order Delivery App
Medical app
Energy App
Privacy Protection app
Good Reads From Our Blog
Stay up-to-date with the latest trends, best practices, and insightful discussions in the world of mobile app development. Explore our blog for articles on everything from platform updates to development strategies.
Essential Guides
Gain a deeper understanding of crucial topics in mobile app development, including platform strategies, user experience best practices, and effective development workflows with expertly crafted guides.













.avif)
