NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile16 min read

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.

fluttergoogle-mapsdeliverylocationarchitecture

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

TermMeaning
MarkerA pin drawn on the map at a coordinate.
PolylineA line drawn on the map through a list of coordinates. Google sends it as a compressed string you decode.
Position fixOne reading of where the device is, with an accuracy figure attached. Never exact.
GeocodingTurning an address into coordinates. Reverse geocoding goes the other way.
AutocompleteThe search field that suggests real places as the customer types.
place_idGoogle's stable identifier for a place. Store this rather than the typed text.
Session tokenA value passed with every keystroke of one search so the whole search bills as a single unit.
DebounceWaiting for a pause in typing before sending a request, so you do not fire one per keystroke.
DirectionsGoogle's paid route-finding service. Returns an encoded polyline and a travel-time estimate.
DeviationHow far the rider has strayed from the route already calculated. Cheap to check locally.
Distance filterReporting a new position only after moving a set distance, rather than on a timer.
InterpolationAnimating a marker smoothly between two known positions instead of jumping it.
GeofenceA circle on the map that fires an event when a device enters or leaves it.
Foreground serviceAn Android background task with a permanent notification, which is what keeps location running when the app is not open.
StaleA position old enough that you should stop presenting it as current.
FirestoreGoogle's hosted database, used here to stream the rider's position to the customer.
Platform viewA real native view embedded inside the Flutter tree. The map widget is one.
CompleterA Dart object you can hand out as a Future now and fill in later, used here for the map controller.

Abbreviations

ShortFull formIn plain words
UIUser InterfaceEverything the user sees and touches
APIApplication Programming InterfaceA service you call over the network, and here also one you are billed for
GPSGlobal Positioning SystemThe satellite system a phone uses to work out where it is
ETAEstimated Time of ArrivalWhen the customer is told the food will land
OEMOriginal Equipment ManufacturerThe phone maker, whose battery manager may kill your background work
APKAndroid Package KitThe installable Android app file, which anyone can unpack and read
IDIdentifierA value that names one specific thing, like an app or an order
MQTTMessage Queuing Telemetry TransportA lightweight messaging protocol suited to frequent small updates
GPXGPS Exchange FormatA file holding a recorded route, useful for replaying trips in a simulator
FAQFrequently Asked QuestionsThe 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.

  1. Pick an address. The customer sets a delivery point.
  2. Draw a route. Show the rider a path and give the customer an ETA.
  3. Track the rider. Move a marker in near real time on the customer's screen.
  4. 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 field

The 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 away

Debounce 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 marker

Three 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.

ThingBilled howWhere it bit me
Map renderper map loadmounting a fresh map widget per screen
Places Autocompleteper session, if tokenisedno session token, billed per keystroke
Directionsper requestrecalculating on every position update
Geocodingper requestreverse geocoding every rider position
Static Mapsper imagecheap, and underused

Four changes cut the bill by roughly 80 percent:

  1. Session tokens plus debouncing on autocomplete.
  2. Route calculated once per order, plus on real deviation.
  3. 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.
  4. 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.

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.