+1 (415) 843-4662

Maps and location in Ionic apps: native Google Maps, MapLibre, clustering and offline tiles

Almost every field-service, delivery, logistics and marketplace app we are asked to build has a map in it, and the map is almost always the screen that gets rebuilt twice. Teams start with an <iframe> or a web JS map inside the WebView, then discover that 2,000 markers makes an ion-content scroll at 8fps, that the map is blank in a tunnel, and that "navigate there" needs to hand off to a native app. This tutorial walks the three decisions that matter — which map renderer, how location is actually acquired, and what happens offline — and gives you working code for each in an Ionic + Capacitor app. Examples are Ionic Angular; the plugin APIs are identical in React and Vue.

1. Pick a renderer: native map view vs WebGL in the WebView

There are three realistic options in 2026.

OptionWhat it isGood forCosts you
@capacitor/google-mapsNative Google Maps SDK rendered behind the WebViewDense marker sets, smooth pan/zoom, familiar UXPer-load billing, no DOM over the map without care
MapLibre GL JSOpen-source WebGL map in the WebViewVector tiles, full styling control, self-hosted or cheap tile vendorsYou own tile hosting/performance
Leaflet + raster tilesClassic DOM/canvas mapSimple pin-on-a-map screens, tiny bundlesRaster only, weak at >500 markers

Rule of thumb from our projects: if the map is the product (dispatch boards, live tracking, thousands of assets) use the native view or MapLibre. If the map is a supporting detail (one store location, a delivery address confirmation) Leaflet is fine and the simplest thing to maintain.

The transparency gotcha with the native map

@capacitor/google-maps renders the native SDK underneath the WebView and punches a hole through it. Anything in your app that paints an opaque background over that hole hides the map. You need the page, ion-content and the host element transparent:

/* map.page.scss */
ion-content { --background: transparent; }

.map-host {
  display: block;
  width: 100%;
  height: 100%;
  background: transparent;
}
/* global.scss — only while a native map page is active */
body.native-map-open,
body.native-map-open ion-app,
body.native-map-open ion-router-outlet { background: transparent !important; }

Toggle that body class in ionViewWillEnter / ionViewWillLeave rather than globally, otherwise every other page loses its background.

2. Mount the native map and clean it up properly

import { Component, ElementRef, ViewChild } from '@angular/core';
import { GoogleMap } from '@capacitor/google-maps';
import { Geolocation } from '@capacitor/geolocation';

@Component({ selector: 'app-map', templateUrl: './map.page.html' })
export class MapPage {
  @ViewChild('mapHost') mapHost!: ElementRef<HTMLElement>;
  private map?: GoogleMap;

  async ionViewDidEnter() {
    document.body.classList.add('native-map-open');

    const start = await this.safeCurrentPosition();

    this.map = await GoogleMap.create({
      id: 'jobs-map',                      // stable id; reused across creates
      element: this.mapHost.nativeElement,
      apiKey: environment.mapsApiKey,
      config: {
        center: { lat: start.lat, lng: start.lng },
        zoom: 12,
        androidLiteMode: false,
      },
    });

    await this.map.enableCurrentLocation(true);
    await this.map.setOnMarkerClickListener((e) => this.openJob(e.markerId));
  }

  async ionViewWillLeave() {
    document.body.classList.remove('native-map-open');
    await this.map?.destroy();            // leaking a map view = leaking a GL surface
    this.map = undefined;
  }
}

Two things teams forget: destroy() on leave (otherwise iOS keeps the GL surface alive and memory climbs with every navigation), and that the map does not follow Ionic page transitions — if you push a modal over it, hide the map or the native view will float above the modal on Android.

3. Location acquisition that works in the field

Geolocation.getCurrentPosition() rejects in more ways than most apps handle: permission denied, location services off at OS level, timeout in a basement, and iOS "approximate location only". Wrap it once:

import { Geolocation } from '@capacitor/geolocation';

export async function safeCurrentPosition() {
  let perm = await Geolocation.checkPermissions();
  if (perm.location === 'prompt' || perm.location === 'prompt-with-rationale') {
    perm = await Geolocation.requestPermissions({ permissions: ['location'] });
  }
  if (perm.location === 'denied') {
    throw new LocationError('denied');     // show a "open settings" sheet
  }

  try {
    const pos = await Geolocation.getCurrentPosition({
      enableHighAccuracy: true,
      timeout: 8000,
      maximumAge: 30_000,                  // a 30s-old fix beats a spinner
    });
    return { lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy };
  } catch {
    const last = await loadCachedFix();    // your own last-known store
    if (last) return last;
    throw new LocationError('unavailable');
  }
}

Always persist the last good fix (Preferences or SQLite) and render the map from it immediately, then re-center when the live fix arrives. The perceived speed difference is large, and it is the difference between a usable and an unusable screen in a parking garage.

On iOS 14+ check accuracy: anything over ~1000m means the user granted approximate location. Detect it and explain what precise location buys them before calling requestTemporaryFullAccuracyAuthorization-style flows; do not nag on first launch.

Permission strings are not optional. Info.plist needs NSLocationWhenInUseUsageDescription (and the Always key only if you genuinely track in the background — App Review rejects speculative Always requests), and AndroidManifest.xml needs ACCESS_FINE_LOCATION plus ACCESS_COARSE_LOCATION.

4. Thousands of markers without killing the frame rate

Never add markers one at a time in a loop across the bridge. Batch them, and cluster.

// one bridge call, not 2000
const ids = await this.map!.addMarkers(
  jobs.map((j) => ({
    coordinate: { lat: j.lat, lng: j.lng },
    title: j.ref,
    iconUrl: j.urgent ? 'assets/pin-urgent.png' : 'assets/pin.png',
  })),
);

await this.map!.enableClustering(3);   // cluster below zoom 3+ groupings

Then only load what is on screen. Listen for camera idle, debounce, and query your API by bounding box:

await this.map!.setOnCameraIdleListener(async (e) => {
  this.bounds$.next(e.bounds);           // debounceTime(300) downstream
});

this.bounds$.pipe(debounceTime(300), switchMap((b) => this.api.jobsInBounds(b)))
  .subscribe((jobs) => this.renderMarkers(jobs));

Diff before you redraw: remove only the marker ids that left the viewport and add only the new ones. Full removeMarkers() + addMarkers() on every pan produces a visible flicker on Android.

For MapLibre the equivalent is a GeoJSON source with cluster: true and clusterRadius: 50 — it clusters on the GPU and handles tens of thousands of points comfortably, which is the main reason to prefer it over Leaflet.

5. Offline maps

Two things can be offline: the tiles and the data.

Tiles. With MapLibre you can ship or download an MBTiles/PMTiles archive and serve it locally. The pragmatic Capacitor pattern is to download the .pmtiles file for the user's region to the filesystem and point MapLibre at it through a local protocol handler:

import { Filesystem, Directory } from '@capacitor/filesystem';
import { Protocol } from 'pmtiles';

const { uri } = await Filesystem.getUri({ directory: Directory.Data, path: 'region.pmtiles' });
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);

const map = new maplibregl.Map({
  container: 'map',
  style: { /* style JSON whose sources use pmtiles://<uri> */ },
});

Budget the download: a metro-area vector extract is typically 20–80 MB. Ask before downloading on cellular, and show progress — silent 60 MB downloads are a support ticket generator.

With @capacitor/google-maps you cannot legally cache tiles yourself. If offline maps are a hard requirement, that decides your renderer for you.

Data. Cache the features themselves in SQLite alongside the rest of your offline store, keyed by tile or geohash, so the map still shows yesterday's jobs with a clear "last updated 14:20" banner instead of an empty canvas.

6. Handing off to turn-by-turn navigation

Do not build turn-by-turn. Hand off to the maps app the user already trusts:

import { Capacitor } from '@capacitor/core';
import { AppLauncher } from '@capacitor/app-launcher';

export async function navigateTo(lat: number, lng: number, label: string) {
  const platform = Capacitor.getPlatform();
  const q = encodeURIComponent(label);

  if (platform === 'ios') {
    const apple = `maps://?daddr=${lat},${lng}&dirflg=d`;
    const { value } = await AppLauncher.canOpenUrl({ url: 'comgooglemaps://' });
    await AppLauncher.openUrl({
      url: value ? `comgooglemaps://?daddr=${lat},${lng}&directionsmode=driving` : apple,
    });
    return;
  }
  // Android: geo: intent, falls back to a chooser
  await AppLauncher.openUrl({ url: `geo:${lat},${lng}?q=${lat},${lng}(${q})` });
}

On iOS, add comgooglemaps and waze to LSApplicationQueriesSchemes in Info.plist or canOpenUrl always returns false.

7. Testing and cost control

  • Simulated routes. Xcode's Debug → Simulate Location → City Run and the Android emulator's GPX playback let you test follow-me behaviour without walking around the block. Make this part of your release checklist.
  • Mock the provider in CI. Your Playwright/Maestro suites should run against a stub tile server; real tile requests in CI are both flaky and billable.
  • Watch map loads. Google Maps bills per map load (SDK) — a page that re-creates the map on every navigation can multiply your bill tenfold. Reuse one map instance per screen and verify the load count in the Cloud console after a QA run.
  • Restrict your key. Separate iOS/Android keys, restricted by bundle id and SHA-1, with a daily quota cap. A leaked unrestricted maps key is a real and common bill shock.

A sensible default stack

For most of the Ionic apps we take over, this is where we land:

  • @capacitor/google-maps when the client is already in Google Cloud, the map is online-only, and marker counts are moderate.
  • MapLibre + a vector tile vendor (or self-hosted PMTiles) when offline maps, heavy clustering or custom cartography matter — it is also the cheaper option at scale.
  • @capacitor/geolocation for foreground fixes, a dedicated background geolocation plugin only if you truly track while backgrounded, and always a cached last-known fix behind both.
  • Native hand-off for navigation, never a homegrown routing UI.

Maps are one of the few Ionic screens where the wrong early decision is expensive to undo, because tiles, billing and offline behaviour are all tangled together. If you are mapping out (sorry) a location-heavy Ionic app and want a second opinion before you commit, get in touch — our Ionic consultants have shipped this screen many times.