Designing a Ride-Sharing App: Finding Nearby Drivers and Matching
1. Overview
A ride-sharing application has two core questions. Which drivers are near a customer? How can we ensure a ride is assigned to exactly one driver, even when dozens of drivers tap “Accept” at the same time?
This article walks through the entire problem in a system design style: requirements, capacity estimation, data models, APIs, and processing flows. Then it focuses on the hard parts: race conditions, consistency, and burst load.
By the end, you’ll be able to build the core “find driver and match ride” feature using Spring Boot 3, Redis, Kafka, and PostgreSQL.
2. Requirements
2.1. Functional requirements
- Drivers send their GPS location periodically when online.
- Customers request rides with pickup and dropoff points.
- The system finds idle drivers nearby and sends them ride offers sequentially.
- Drivers accept or decline. If a driver doesn’t respond within 15 seconds, the system sends the offer to the next candidate.
- Customers can track the ride status:
REQUESTED → ACCEPTED → IN_PROGRESS → COMPLETED(orCANCELLED).
2.2. Scale and constraints
Assume the system operates in a mid-sized country:
- During peak hours, 300,000 drivers are online, each sending their location every 4 seconds. That’s roughly 75,000 location writes per second.
- There are around 1,500 ride requests per second. During rain or rush hour, this can increase 5x.
- Finding a driver must complete within 200 ms (p99).
- A ride must be assigned to exactly one driver. A driver can have at most one active ride. This constraint must not be violated.
From these numbers, an important observation emerges. Driver location is high-write, short-lived data that can tolerate loss: losing a single GPS point is fine. Conversely, ride data is written less frequently but needs strict consistency. Therefore, these two types of data should reside in different stores.
3. Data Model
Driver locations are stored in Redis. Rides are stored in PostgreSQL:
CREATE TABLE rides (
id BIGSERIAL PRIMARY KEY,
customer_id VARCHAR(64) NOT NULL,
driver_id VARCHAR(64),
pickup_lat DOUBLE PRECISION NOT NULL,
pickup_lng DOUBLE PRECISION NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
created_at TIMESTAMP NOT NULL DEFAULT now(),
accepted_at TIMESTAMP
);
-- Each driver can have at most one active ride
CREATE UNIQUE INDEX ux_rides_driver_active
ON rides (driver_id)
WHERE status IN ('ACCEPTED', 'IN_PROGRESS');
The partial unique index at the end is a final safety net. Suppose there’s a bug in the application layer that tries to assign two rides to the same driver. The database will reject the second write.
Redis uses three types of keys:
drivers:available GEO set: driverId -> (lng, lat), contains only idle drivers
driver:heartbeat:{driverId} String, TTL 30s, marks the driver as still online
driver:lock:{driverId} String, TTL 15s, value = rideId being offered
4. API
POST /api/drivers/{driverId}/location { "lat": 10.7769, "lng": 106.7009 }
POST /api/rides { "customerId": "c-42", "pickupLat": ..., "pickupLng": ... }
POST /api/rides/{rideId}/accept { "driverId": "d-7" }
GET /api/rides/{rideId}
The location update endpoint carries heavy load. In practice, this typically goes through WebSocket or gRPC streaming to avoid opening an HTTP connection every 4 seconds. To keep the example simple, this article still uses REST.
5. Storing and Finding Driver Locations with Redis GEO
Add the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Update location
@Service
public class DriverLocationService {
static final String AVAILABLE_KEY = "drivers:available";
private final StringRedisTemplate redis;
public DriverLocationService(StringRedisTemplate redis) {
this.redis = redis;
}
public void updateLocation(String driverId, double lng, double lat) {
redis.opsForGeo().add(AVAILABLE_KEY, new Point(lng, lat), driverId);
redis.opsForValue().set("driver:heartbeat:" + driverId, "1", Duration.ofSeconds(30));
}
public void markBusy(String driverId) {
redis.opsForGeo().remove(AVAILABLE_KEY, driverId);
}
}
GEOADD stores coordinates as geohash in a sorted set. Each command has O(log N) complexity, so a single Redis node can handle tens of thousands of commands per second without trouble.
The heartbeat key exists because members in a GEO set have no individual TTL. If a driver loses signal or closes the app, their old coordinates remain in the set. The heartbeat key, expiring after 30 seconds, helps us filter out these “ghost” drivers.
Note: Redis GEO takes arguments in (longitude, latitude) order. Swapping them is a common mistake. When reversed, drivers in Ho Chi Minh City end up at the South Pole.
5.2. Find nearby drivers
public List<String> findNearby(double lng, double lat, double radiusKm, int limit) {
var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.sortAscending()
.limit(limit * 2L); // fetch more to account for filtered drivers
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search(
AVAILABLE_KEY,
GeoReference.fromCoordinate(lng, lat),
new Distance(radiusKm, Metrics.KILOMETERS),
args);
List<String> candidates = results.getContent().stream()
.map(r -> r.getContent().getName())
.toList();
List<String> alive = redis.opsForValue().multiGet(
candidates.stream().map(id -> "driver:heartbeat:" + id).toList());
List<String> online = new ArrayList<>();
for (int i = 0; i < candidates.size() && online.size() < limit; i++) {
if (alive.get(i) != null) {
online.add(candidates.get(i));
}
}
return online;
}
GEOSEARCH returns drivers within the given radius, sorted from nearest to farthest. Then a single MGET command checks the heartbeat of all candidates, costing just one extra round-trip. For a customer in District 1 with a 3 km radius, results might look like:
[d-7 (0.4 km), d-19 (0.9 km), d-3 (1.6 km)]
The distance here is as-the-crow-flies. In production, you should re-rank the top-N candidates using actual ETA from a mapping service. The reason is that a driver 500 m away but on the other side of a river might arrive slower than a driver 1 km away.
6. Matching Rides and Handling Race Conditions
There are two types of race conditions to prevent:
- Two rides inviting the same driver. Two customers standing near each other request rides simultaneously, and both matching threads pick
d-7. - Two drivers accepting the same ride. This happens when you send offers in parallel to multiple drivers to reduce wait time.
6.1. Lock drivers with Redis during invitation
@Service
public class DriverLockService {
private static final RedisScript<Long> RELEASE = RedisScript.of("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""", Long.class);
private final StringRedisTemplate redis;
public DriverLockService(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryLock(String driverId, long rideId) {
return Boolean.TRUE.equals(redis.opsForValue()
.setIfAbsent("driver:lock:" + driverId, String.valueOf(rideId), Duration.ofSeconds(15)));
}
public void release(String driverId, long rideId) {
redis.execute(RELEASE, List.of("driver:lock:" + driverId), String.valueOf(rideId));
}
}
SET NX EX is an atomic operation. Only one matching thread can acquire the lock for each driver, and it automatically expires after 15 seconds, matching the driver response timeout. Thanks to this TTL, the system automatically “moves to the next candidate” when there’s no response, without needing a cleanup task.
The Lua script for releasing the lock only deletes the key if its value matches rideId. If you use bare DEL, ride A’s expired lock might accidentally delete the lock ride B just acquired.
6.2. Finalize rides with conditional update
Redis lock is only an optimization to avoid sending duplicate offers. The database remains the source of truth:
@Repository
public class RideRepository {
private final JdbcTemplate jdbc;
public RideRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean assignDriver(long rideId, String driverId) {
int updated = jdbc.update("""
UPDATE rides
SET status = 'ACCEPTED', driver_id = ?, accepted_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'REQUESTED'
""", driverId, rideId);
return updated == 1;
}
}
The condition status = 'REQUESTED' turns the UPDATE into a compare-and-set operation. When two drivers tap accept simultaneously, the database serializes both writes to the same row. The first command returns 1. The second sees the status is already ACCEPTED and returns 0. This approach doesn’t need SELECT ... FOR UPDATE or a separate version column.
The service ties the acceptance steps together:
@Transactional
public AcceptResult accept(long rideId, String driverId) {
if (!rideRepository.assignDriver(rideId, driverId)) {
return AcceptResult.ALREADY_TAKEN;
}
locationService.markBusy(driverId);
lockService.release(driverId, rideId);
return AcceptResult.ACCEPTED;
}
The order here is deliberate: write to database first, update Redis after. If the markBusy step fails, the driver remains in the idle list for a few more seconds. The worst consequence is the driver receives another offer, and the partial unique index will reject the second ride assignment. The reverse order is riskier: deleting the driver from Redis and then having the database write fail would cause the driver to “disappear” from the system.
7. Asynchronous Processing with Kafka
Finding a driver, sending an offer, and waiting 15 seconds is a long process. You shouldn’t keep the customer’s HTTP request alive throughout. The POST /api/rides API only saves the ride and returns 202 Accepted immediately. The matching process runs in a separate consumer.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
public record RideRequested(long rideId, String cityId, double lng, double lat, int attempt) {}
@Component
public class MatchingConsumer {
private final DriverLocationService locations;
private final DriverLockService locks;
private final OfferSender offerSender;
private final KafkaTemplate<String, RideRequested> kafka;
// constructor omitted for brevity
@KafkaListener(topics = "ride-requested", groupId = "matching")
public void onRideRequested(RideRequested event) {
for (String driverId : locations.findNearby(event.lng(), event.lat(), 3, 10)) {
if (locks.tryLock(driverId, event.rideId())) {
offerSender.send(driverId, event.rideId()); // push notification / WebSocket
return;
}
}
if (event.attempt() < 5) {
// no one available: retry later
kafka.send("ride-requested-retry", event.cityId(),
new RideRequested(event.rideId(), event.cityId(), event.lng(), event.lat(), event.attempt() + 1));
}
}
}
The consumer iterates through candidates from nearest to farthest. It locks the first available driver and sends them an offer. If no one is available, the event is pushed to a retry topic, capped by retry limits.
The message key is cityId. This ensures all rides in a city fall into the same partition group, and the consumer’s local driver cache (if any) achieves higher hit rate.
There’s one small issue: saving the ride to PostgreSQL and then sending to Kafka are two separate operations. If the application dies in between, the ride gets “stuck” in REQUESTED status forever. The proper fix is transactional outbox. You write events to an outbox table in the same transaction as the rides table, then let a separate process (or Debezium) read the outbox table and push to Kafka.
Note: Kafka guarantees at-least-once delivery, so a consumer may receive an event twice. Matching logic must be idempotent. Before sending an offer, check that the ride is still in
REQUESTEDstatus. The conditional update in section 6.2 is already safe against duplicates.
8. Handling Burst Load
When it rains at 5 PM, both ride request volume and the number of drivers opening the app spike. There are four main strategies.
- Shard location data by region. Instead of using a single
drivers:availablekey for the entire country, split by city or geohash cell, likedrivers:available:hcm. Each key lands on a shard of a Redis Cluster, so write load is distributed evenly. When a customer stands near a cell boundary, query the adjacent cells too. - Reduce location update frequency. A stationary driver or one with a passenger doesn’t need to send location every 4 seconds. Only send when they move over 50 m or after 15 seconds. This optimization alone can reduce write volume by 40–60%.
- Use backpressure instead of falling over. Kafka acts as a buffer. When ride requests increase 5x, matching latency increases by a few seconds, but no rides are lost. Scale the number of consumers based on consumer lag, capped by the number of partitions.
- Dynamic pricing (surge pricing). This is a product-layer solution, not a technical one. Higher prices reduce demand and attract more drivers online, helping rebalance the system.
Additionally, rate limit the ride request API by customerId. When the app shows “finding a vehicle” for too long, customers tend to tap the request button repeatedly, creating duplicate load spikes.
9. Testing Race Conditions on Accept
The most important behavior to test is: when multiple drivers accept the same ride, only one wins. The test below uses H2 in-memory through @JdbcTest. The file src/test/resources/schema.sql contains the rides table from section 3, minus the partial index since H2 doesn’t support it.
@JdbcTest
@Import(RideRepository.class)
class RideRepositoryTest {
@Autowired
RideRepository rideRepository;
@Autowired
JdbcTemplate jdbc;
@Test
void givenConcurrentAccepts_whenAssignDriver_thenOnlyOneDriverWins() throws Exception {
jdbc.update("INSERT INTO rides (id, customer_id, pickup_lat, pickup_lng, status) "
+ "VALUES (1, 'c-42', 10.77, 106.70, 'REQUESTED')");
List<String> drivers = List.of("d-1", "d-2", "d-3", "d-4", "d-5");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Boolean>> results = drivers.stream()
.map(d -> executor.submit(() -> rideRepository.assignDriver(1L, d)))
.toList();
long winners = 0;
for (Future<Boolean> f : results) {
if (f.get()) winners++;
}
assertThat(winners).isEqualTo(1);
}
String status = jdbc.queryForObject("SELECT status FROM rides WHERE id = 1", String.class);
assertThat(status).isEqualTo("ACCEPTED");
}
}
Five virtual threads call assignDriver on the same ride simultaneously. No matter what the execution order is, only one UPDATE matches the status = 'REQUESTED' condition.
You might notice the test removed the default @Transactional from @JdbcTest. The reason: new threads don’t run in the same transaction as the test method, and the INSERT is still visible to them because data was committed per auto-commit settings. If you get winners = 0, add @Transactional(propagation = Propagation.NOT_SUPPORTED) to the test class.
For GEO logic, use Testcontainers to run a real Redis instance instead of mocking. Distance calculation and sorting order are things a mock cannot properly verify.
10. Conclusion
This article has sketched the core of a ride-sharing application. The key point is to separate data by its characteristics:
- Driver location is high-write and loss-tolerant, so put it in Redis GEO with heartbeats to filter disconnected drivers.
- Rides need consistency, so put them in PostgreSQL. Conditional update acts as compare-and-set, and partial unique index is the final safety net.
- Redis lock with TTL prevents duplicate driver invitations and handles timeouts automatically. However, the lock is just an optimization, not the source of truth.
- Kafka separates matching from customer requests, absorbs burst load, and enables retries. The outbox pattern ensures events aren’t lost.
As you expand, consider these additional directions: rank candidates by actual ETA instead of as-the-crow-flies distance, batch matching every few seconds for global optimization, and ride pooling. All of these can be built on the foundation presented here.