How I Use Google Maps in a Delivery App
google_maps_flutter in production, live rider tracking, route drawing, background location on hostile OEMs, and the billing traps that make the map the cheapest part.
Every delivery app looks like a map problem and turns out to be a location problem. The map is the easy part. You render a widget, drop some markers, and it looks finished in an afternoon.
What takes the rest of the time is everything the map is showing: where the rider actually is, how often you find out, what you do when the phone is in a pocket with the screen off, and how much all that costs you per month at Google's rates.
This is how I put that together, and the specific places it went wrong first.
Glossary
Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.
The one sentence the rest of this post expands: the map is a display layer, the real system is a stream of unreliable position readings, and most of the work is making that stream cheap and honest.
Terms
| Term | Meaning |
|---|---|
| Marker | A pin drawn on the map at a coordinate. |
| Polyline | A line drawn on the map through a list of coordinates. Google sends it as a compressed string you decode. |
| Position fix | One reading of where the device is, with an accuracy figure attached. Never exact. |
| Geocoding | Turning an address into coordinates. Reverse geocoding goes the other way. |
| Autocomplete | The search field that suggests real places as the customer types. |
place_id | Google's stable identifier for a place. Store this rather than the typed text. |
| Session token | A value passed with every keystroke of one search so the whole search bills as a single unit. |
| Debounce | Waiting for a pause in typing before sending a request, so you do not fire one per keystroke. |
| Directions | Google's paid route-finding service. Returns an encoded polyline and a travel-time estimate. |
| Deviation | How far the rider has strayed from the route already calculated. Cheap to check locally. |
| Distance filter | Reporting a new position only after moving a set distance, rather than on a timer. |
| Interpolation | Animating a marker smoothly between two known positions instead of jumping it. |
| Geofence | A circle on the map that fires an event when a device enters or leaves it. |
| Foreground service | An Android background task with a permanent notification, which is what keeps location running when the app is not open. |
| Stale | A position old enough that you should stop presenting it as current. |
| Firestore | Google's hosted database, used here to stream the rider's position to the customer. |
| Platform view | A real native view embedded inside the Flutter tree. The map widget is one. |
Completer | A Dart object you can hand out as a Future now and fill in later, used here for the map controller. |
Abbreviations
| Short | Full form | In plain words |
|---|---|---|
| UI | User Interface | Everything the user sees and touches |
| API | Application Programming Interface | A service you call over the network, and here also one you are billed for |
| GPS | Global Positioning System | The satellite system a phone uses to work out where it is |
| ETA | Estimated Time of Arrival | When the customer is told the food will land |
| OEM | Original Equipment Manufacturer | The phone maker, whose battery manager may kill your background work |
| APK | Android Package Kit | The installable Android app file, which anyone can unpack and read |
| ID | Identifier | A value that names one specific thing, like an app or an order |
| MQTT | Message Queuing Telemetry Transport | A lightweight messaging protocol suited to frequent small updates |
| GPX | GPS Exchange Format | A file holding a recorded route, useful for replaying trips in a simulator |
| FAQ | Frequently Asked Questions | The question section near the end |
The four map jobs in a delivery app
It helps to name them, because they use different APIs with different prices and different failure modes.
- Pick an address. The customer sets a delivery point.
- Draw a route. Show the rider a path and give the customer an ETA.
- Track the rider. Move a marker in near real time on the customer's screen.
- Detect arrival. Know when the rider reaches the drop point.
Only the first two touch Google's paid APIs on every use. Tracking and arrival are your own infrastructure, and that is where the engineering lives.
Flutter setup, briefly
Four packages carry this, and they do not overlap:
dependencies:
google_maps_flutter: ^2.9.0 # the map widget itself
geolocator: ^13.0.0 # device position and distance maths
flutter_polyline_points: ^2.1.0 # decodes the Directions polyline
google_places_flutter: ^2.0.9 # autocomplete fieldThe API key goes in the platform projects, not in Dart. On Android it is a
meta-data entry in AndroidManifest.xml, on iOS a GMSServices.provideAPIKey
call in AppDelegate.swift. Restrict both keys by application ID and by API in
the Google Cloud console before the app leaves your machine. An unrestricted key
in a shipped APK is a key somebody else is using by next week.
The map itself is one widget. You give it a starting camera position, the pins and lines to draw, and a callback that hands you a controller for moving the camera later.
GoogleMap(
initialCameraPosition: CameraPosition(target: dropPoint, zoom: 15),
markers: markers,
polylines: polylines,
myLocationEnabled: true,
// The default control cluster fights with your own UI on a delivery
// screen, where the bottom half is order state.
zoomControlsEnabled: false,
onMapCreated: (controller) => _controller.complete(controller),
)Hold the GoogleMapController in a Completer rather than a plain field. The
widget builds before the platform view is ready, and anything that moves the
camera has to await it or it silently does nothing.
Picking an address
The naive version is a map with a centre pin and a "confirm location" button. It ships fast and it produces bad addresses, because a pin on a rooftop tells a rider nothing about which gate to use.
What works better is a combination:
Places Autocomplete for the search field. The customer types, Google
suggests, you store the place_id rather than the text. A place_id is stable.
A typed string is not.
A draggable pin for the exact drop point, initialised from the selected place. This is where the customer corrects "the building" into "the side entrance".
A free-text note field. No geocoder replaces "blue gate, ring twice". In Kathmandu, where many addresses are landmark-based rather than numbered, this field is not a nicety. It is often the only part a rider actually uses.
final prediction = await places.autocomplete(
query,
// Bias results to the delivery city so you stop suggesting
// the same street name from three countries away.
location: LatLng(27.7172, 85.3240),
radius: 20000,
components: [Component(Component.country, "np")],
);The components filter is the single highest-value line here. Without it,
autocomplete happily suggests a street in another country that shares a name,
and someone will pick it.
Going deeper: session tokens are a billing decision
Autocomplete is billed per request, but if you pass a session token, a whole typing session plus the final Place Details call bills as one unit. Forget the token and you pay for each keystroke's request separately.
final sessionToken = const Uuid().v4();
// pass the same token for every keystroke, then for the details call
// once the customer picks a suggestion, then throw it awayDebounce as well, around 300ms. Between debouncing and session tokens, this screen went from the most expensive thing in the app to a rounding error.
Drawing the route
A route on screen is just a line through a long list of coordinates. Google does not send that list. It sends a compressed string standing in for it, which keeps the response small, and you unpack it on the device.
Directions API gives you an encoded polyline, not a list of points. Decode it and hand it to the map.
final response = await directions.route(
origin: riderPosition,
destination: dropPoint,
mode: TravelMode.driving,
);
// flutter_polyline_points turns the encoded string into LatLng values.
// Decoding a long route is real work, so it does not belong on the UI
// thread for anything cross-city.
final decoded = PolylinePoints().decodePolyline(encoded);
final points = decoded.map((p) => LatLng(p.latitude, p.longitude)).toList();
setState(() {
polylines = {
Polyline(
polylineId: const PolylineId("route"),
points: points,
width: 4,
),
};
});Two things I got wrong here.
I recalculated the route on every rider position update. That is a Directions call every few seconds per active order. It is accurate, it looks great, and it will produce a bill that gets you a meeting. Now the route is calculated once when the order is accepted, and again only if the rider deviates from it by more than about 100 metres. Deviation is a cheap local calculation against the polyline you already have.
I used the Directions ETA as the customer-facing ETA. Google's ETA is a driving-time estimate. It knows nothing about the ten minutes the food spends waiting on the counter, or that your rider is on a scooter in traffic that cars are stuck in. The number shown to customers is now Google's travel time plus a preparation estimate from the restaurant, and it is deliberately rounded up. Under-promising is worth more than precision here.
Tracking the rider
This is the part that actually decides whether the app feels alive, and none of it is Google Maps. The map is just the thing displaying the result.
The pipeline
Nothing exotic happens here. The rider's phone works out where it is, sends that somewhere, and the customer's phone reads it back out. Three hops, and every interesting decision is about how often each hop happens.
rider device backend customer device
──────────── ─────── ───────────────
GPS fix ──► write position ──► stream position
every 5s or 20m to Firestore animate markerThree separate decisions hide in that diagram.
How often the rider's device reports. Not "as often as possible". Distance filtering beats time filtering: report when the rider has moved 20 metres, or every 5 seconds, whichever comes first. A stationary rider at a traffic light should not be generating writes.
final stream = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 20,
),
);Where it is written. One document per active order, overwritten in place. Not a collection of position history. History is interesting to you and useless to the customer, and it multiplies your write count by every fix you take. If you want history for disputes, batch it into the order document when the trip ends.
How the customer's screen consumes it. A position stream, and here is the detail that makes it look professional: do not teleport the marker. Animate between the last position and the new one over the interval you expect the next update in.
void _moveMarker(LatLng from, LatLng to) {
_controller
..duration = const Duration(seconds: 5)
..forward(from: 0);
_animation = LatLngTween(begin: from, end: to).animate(_controller);
}Without interpolation, a 5-second update interval reads as a broken app. With it, the same data reads as smooth live tracking. Nothing changed except the rendering.
Two Flutter specifics that cost me time. BitmapDescriptor.asset needs an
ImageConfiguration carrying the device pixel ratio, or your rider icon is
blurry on every phone made in the last decade. And rebuilding the markers set
on every frame of the animation is fine, because the set is small, but rebuilding
the whole GoogleMap widget is not. Keep the map above the animated part of the
tree.
Going deeper: background location is the real work
The rider's phone will be locked, in a pocket, with the app backgrounded. That is the normal case, not the edge case, and both platforms are actively hostile to what you want to do.
On Android you need a foreground service with a persistent notification, plus
ACCESS_BACKGROUND_LOCATION, and the app has to survive aggressive OEM battery
managers. Xiaomi, Oppo and Samsung each kill background work differently, and
some require the user to manually exempt your app.
On iOS you need the Always authorisation and the background location mode, and the permission dialog asks the user twice: once at first request, and again later. Many people say no the second time.
The lesson that took longest to accept: you cannot rely on a continuous stream. Design for gaps. The backend treats a position as valid for a short window and marks the rider stale after it, and the customer's UI says "updating location" instead of showing a marker frozen where it was ten minutes ago. An honest stale state beats a confident wrong one.
Detecting arrival
A geofence is a circle drawn around the drop point that the operating system watches for you, firing an event when the rider crosses into it. It sounds like exactly the right tool.
The obvious approach is geofencing. What I use is simpler and more reliable: compute the distance between the rider's last position and the drop point on every update, and treat under 50 metres as arrived.
final metres = Geolocator.distanceBetween(
rider.latitude, rider.longitude,
drop.latitude, drop.longitude,
);
if (metres < 50 && !order.arrivedNotified) {
await notifyCustomerArrival(order.id);
}Fifty metres, not ten. GPS in a dense urban area drifts, and the difference between the street and the building is inside the error bar. A notification that fires slightly early is fine. One that never fires because the rider stopped across the street is a support ticket.
Geofencing APIs are worth it when you need arrival detection with the app fully closed. If your rider app is already streaming positions, you have everything you need without them.
What it costs, and where
Google Maps billing surprises people because the map itself is the cheap part.
| Thing | Billed how | Where it bit me |
|---|---|---|
| Map render | per map load | mounting a fresh map widget per screen |
| Places Autocomplete | per session, if tokenised | no session token, billed per keystroke |
| Directions | per request | recalculating on every position update |
| Geocoding | per request | reverse geocoding every rider position |
| Static Maps | per image | cheap, and underused |
Four changes cut the bill by roughly 80 percent:
- Session tokens plus debouncing on autocomplete.
- Route calculated once per order, plus on real deviation.
- Static Maps images for order history. A past order does not need an interactive map. It needs a picture of a route. A static image costs a fraction of a map load and renders instantly in a list.
- Stop reverse geocoding rider positions. I was converting every position to an address for a "rider is near X street" label nobody had asked for.
Set a billing alert on day one. Not because you expect to hit it, but because the failure mode of a loop that calls a paid API is a bill rather than a crash, and you will not notice it in testing.
Key takeaways
- The map is the easy part. The location pipeline is where the work is.
- Store
place_id, not address text. Text is not stable and cannot be re-resolved. - Always filter autocomplete by country and bias by city, or you will deliver to the wrong continent's version of a street name.
- Session tokens turn a per-keystroke bill into a per-search bill.
- Calculate the route once, then only on meaningful deviation. Recalculating per position update is the most expensive mistake available.
- Distance filter, not time filter, for position reporting. A stationary rider should be quiet.
- Interpolate the marker. The same data looks broken or smooth depending entirely on whether you animate between fixes.
- Design for gaps in background location. Show a stale state honestly instead of a frozen marker.
- Use Static Maps for history. Interactive maps in a list is money set on fire.
- Set a billing alert before you ship.
FAQ
Should I use Google Maps or Mapbox?
Google if you need the best address data and Places coverage, which in South Asia is a real advantage. Mapbox if styling and predictable pricing matter more than address quality. The tracking architecture above is the same either way.
How often should the rider app send its position?
Start with a 20 metre distance filter and a 5 second cap, then tune from real trips. More frequent updates rarely improve the customer's experience once you interpolate the marker, and they cost battery and writes.
Do I need a realtime database for tracking?
You need something that streams. Firestore works and is simple. At higher volume a dedicated channel such as MQTT or a WebSocket service is cheaper, because Firestore bills per document write and positions are a write-heavy workload.
How do I test this without riding around the city?
Both simulators support GPX route playback, and Android emulators accept scripted coordinates. Build a mock position provider behind the same interface as the real one, so you can replay a trip in seconds.
What about offline?
Riders lose signal. Queue positions locally with timestamps and flush them when connectivity returns, and always send the timestamp from the device rather than trusting the write time on the server.
Conclusion
The instinct is to treat this as a maps feature, and to measure progress by how good the map looks. The map looked good on day two, and the app was still not usable for another month.
What made it work was accepting that location data is unreliable by nature. The fixes are wrong sometimes, they stop arriving when the phone decides to sleep, and they cost money every time you turn them into something human-readable. Once the system is designed around that, rather than around the happy path where a position arrives every five seconds forever, the map becomes what it should have been from the start: a display layer over data you already trust.
References
Official documentation for the topics covered here.
- pub.dev: google_maps_flutter - the map widget itself, controller, markers and polylines, plus per-platform API key setup
- Google Maps Platform: Place Autocomplete - place autocomplete,
place_id, and the session token that makes a search bill as one unit - Google Maps Platform: Directions API - the routing service, its encoded polyline response and the travel time estimate
- Google Maps Platform: Geocoding API - turning an address into coordinates and back again
- pub.dev: geolocator - position streams, accuracy settings and the distance filter
- Firebase: Cloud Firestore - the realtime document store behind the rider position stream, and how its listeners bill
- Android: Foreground services - the Android rules for keeping location running with the app closed
- Apple: Core Location - iOS location authorisation levels and background updates
Read more
Offline-First Flutter covers the queueing and sync patterns that keep a rider app usable through dead zones, and Flutter Isolates Explained is worth reading before you start decoding large polylines on the UI thread.