One of the key challenges in a food delivery service is ensuring that orders reach customers quickly while minimizing courier idle time (the time when they're online but without an active order). It seems simple: a customer places an order, the restaurant prepares it, and all that's left is to find the nearest available courier and send them to pick up the food. However, in reality, it's much more complex.
In this article, I'll explain how we select the optimal courier for each order and how our approach has evolved over time. I'll cover two fundamentally different methods for solving this problem.
Overall Assignment Architecture
When a user confirms an order, an order object is created on the backend, which goes through various states according to programmed logic. For an order to transition from "Waiting for courier" to "Courier assigned" status, we need to find an available courier, offer them the order, and receive confirmation of acceptance.
Greedy Algorithm
For a long time, Mixfood used the greedy approach. With this method, during the courier search stage, the system queries the "Courier Distribution" microservice, which is responsible for all courier information. The microservice knows everything about each courier: from their transport type to current geolocation and status. It has local geotags with courier data and integration with route-building services to calculate distances and travel times from point A to point B.
When a courier search request comes in, "Courier Distribution" first identifies the nearest couriers by direct radius, considering basic order requirements (specific thermal bag type, transport type). Then the system refines the actual time and route length to the restaurant and selects the best option based on this information.
Over time, this logic evolved: we began calculating a "rating" for each courier for a specific order—a function that considers not only arrival time at the restaurant but also many other parameters: from demand levels in pickup and delivery areas to the courier's experience and reliability. Couriers are ranked by this metric. This method is called "bonus assignment."
Buffer (Batch) Approach
However, with the greedy method, whoever places an order first gets the nearest courier. This means some customers may be left without a courier, especially during peak hours.
During high demand, when competition for available couriers begins, the greedy algorithm works inefficiently. To maximize demand satisfaction even during the busiest hours, we employ various approaches. One of them is buffer (batch) courier assignment. It's based on a classic combinatorial optimization problem — the assignment problem. The essence: suppose we have N orders and M couriers, any courier can fulfill any order in time p(i,j). We need to assign each order to a courier in a way that minimizes the total delivery time for all orders (with each courier taking only one order at a time—though we need to automate the ability to take multiple orders. Currently, this is done through operator intervention and manual addition of second and third orders if they're along the route and time-aligned).
When solving this problem, our "cost" of fulfilling an order by a specific courier is the rating function value based on arrival time at the restaurant and delivery to the customer. The problem can be described in terms of bipartite graphs: on one side—orders, on the other—couriers. Between them are ratings. Thus, our goal is to minimize total delivery time while maximizing the number of fulfilled orders (maximum matching). One of the most well-known ways to solve this problem is the Hungarian algorithm.
Technical Implementation of the Buffer Approach
Obviously, with buffer assignment, we can't instantly assign a courier as with the greedy method. First, we need to place the order in a queue, perform distribution optimization, and only then notify about the found courier. This didn't fit into the existing order processing logic, so it had to be improved.
To test the new solution without affecting system operations, we created a separate microservice. It accepts orders, adds them to its queue, finds optimal couriers, and stores distribution results.
First, we needed to prepare the microservice for the new load profile. If with the greedy approach, requests came individually and were evenly distributed across instances, in buffer assignment all requests arrive simultaneously. Therefore, we added batch request processing capability, which is handled in parallel within "Courier Distribution." We also had to resolve the issue of optimal request quantity per batch. On the client side, we break large batches into several smaller ones and send them to different "Courier Distribution" instances.
Integration with the Main System
After "Courier Distribution" was prepared, ratings are calculated both in the old system (greedy assignment) and in the new service ("Courier Distribution - 2"), and the assignment problem algorithm was fine-tuned—the question arose of integrating this into the main order processing logic.
We added metadata transmission about orders to "Courier Distribution - 2" when their status changes. Initially, we planned to simply replace the request to "Courier Distribution" with a request to our service, but that's a bad idea: we need to offer the order to the courier immediately once a match is found, and even a few seconds of delay is critical—the courier might drive in a different direction. Therefore, we implemented the ability to trigger the search process outside the standard scheduler.
This allowed us to combine the main order processing logic with the buffer dispatcher logic without affecting the working system and without state conflicts.
Wait Time and Optimization
Does courier search time increase if the search doesn't happen immediately? Not exactly: using various techniques (including machine learning), we've identified cases where waiting makes sense. In other cases, wait time remains unchanged, and overall delivery time even decreases due to more optimal distribution.
Pre-Assignment at the Ordering Stage
Another way to speed up delivery is to start looking for a courier BEFORE the order is finalized. This is how DoorDash does it. When a user is just selecting dishes and entering an address, machine learning algorithms assess the probability that they'll actually complete the order and decide whether to consider it in buffer courier search. We can find a courier in advance, and when the user confirms the order—immediately make an offer to the appropriate courier.
Conclusions
Distributing orders among couriers is a complex task that requires considering many factors: from geographical location to demand forecasting and the context of couriers' current routes. The right combination of greedy and buffer approaches allows us to optimize both delivery time for customers and courier work efficiency.
Top comments (0)