Skip to content

Offline Regions

Offline regions allow users to download a geographic area and use the map without internet access. The map tiles, fonts, and sprites are all stored on-device.

Android and iOS only, and iOS can evict

MapLibre GL JS (web) has no offline API, so guard all offline code with if (!kIsWeb). On iOS the downloaded tiles live in the app's Library/Caches, which the system may clear under low storage, so treat a downloaded region as a cache and check it is still complete before relying on it.

How it works

flowchart TD
    APP["Your app"] --> CALL["downloadOfflineRegion(<br/>definition, onEvent)"]
    CALL --> BG["MapLibre Native downloads<br/>tiles in the background"]
    BG --> EV["onEvent(status)<br/>called periodically<br/><small>completed / required count<br/>InProgress · Success · Error</small>"]
    BG --> CACHE["Tiles stored in MapLibre's<br/>SQLite cache on device"]

    classDef emphasis fill:#1f6feb,stroke:#1a5fd0,color:#fff;
    class APP emphasis

Once downloaded, the map works offline automatically; no code change needed. When a region is in the cache and the device is offline, MapLibre serves tiles from the local store.

Step 1: Define the region

OfflineRegionDefinition describes what to download:

import 'package:maplibre_gl/maplibre_gl.dart';

final definition = OfflineRegionDefinition(
  bounds: LatLngBounds(
    southwest: const LatLng(48.7, 2.2),  // Paris area
    northeast: const LatLng(49.0, 2.5),
  ),
  minZoom: 10.0,   // download from zoom 10
  maxZoom: 16.0,   // to zoom 16 (street level)
  mapStyleUrl: MapLibreStyles.openfreemapLiberty,
  includeIdeographs: false,  // set true for CJK character support
);

Tile count and the 6,000 tile cap

Tile count grows exponentially with zoom levels: a 10x10 km area at zoom 10 to 16 can be thousands of tiles, and a region holds 6,000 by default (raise it with setOfflineTileCountLimit). Keep the bounds tight and the zoom range short instead of downloading a whole city at street level in one go.

Step 2: Download with a progress callback

import 'package:flutter/foundation.dart'; // for kIsWeb

Future<void> downloadRegion() async {
  if (kIsWeb) return; // offline not supported on web

  final region = await downloadOfflineRegion(
    definition,
    metadata: {'name': 'Paris Center'}, // arbitrary metadata
    onEvent: (DownloadRegionStatus status) {
      if (status is InProgress) {
        final progress = status.requiredResourceCount > 0
            ? status.completedResourceCount / status.requiredResourceCount
            : 0.0;
        setState(() => _progress = progress);
      } else if (status is Success) {
        print('Download complete! Region ID: ${region.id}');
      } else if (status is Error) {
        print('Download failed: ${status.cause.message}');
      }
    },
  );
}

onEvent is called with a DownloadRegionStatus, which comes in three shapes: InProgress while tiles are arriving (it carries the resource counts and byte total), Success once the region is fully downloaded, and Error with the underlying cause if the download fails. Switch on the type rather than reading a state field.

downloadOfflineRegion returns an OfflineRegion object with the assigned id. Store this ID if you need to delete the region later.

metadata is stored as jsonEncode writes it, so a Uint8List comes back as a list of numbers.

Download events reach only the root isolate. In a background isolate, passing onEvent throws an UnsupportedError: leave it out and follow the download with getOfflineRegionStatus.

Re-downloading the same area

Downloading a region whose area (bounds, zoom range and style) matches an already-downloaded one replaces the existing region instead of creating a duplicate. On iOS the replacement keeps the id the previous region had; on Android the SDK assigns a new id when it creates the region. Read the id back from the returned OfflineRegion rather than assuming either.

Step 3: Track progress in UI

double _progress = 0.0;
bool _isDownloading = false;

// In your build():
if (_isDownloading)
  LinearProgressIndicator(value: _progress)
else
  ElevatedButton(
    onPressed: _startDownload,
    child: const Text('Download for offline'),
  )

OfflineRegionStatus fields

Field Description
completedResourceCount Tiles and assets downloaded so far
requiredResourceCount Total tiles and assets needed
completedResourceSize Bytes downloaded
downloadProgress Percentage complete, 0 to 100
isComplete Whether the region has finished downloading

List downloaded regions

final List<OfflineRegion> regions = await getListOfRegions();

for (final region in regions) {
  print('Region ${region.id}: ${region.metadata}');
  print('  Bounds: ${region.definition.bounds}');
  print('  Zooms: ${region.definition.minZoom} - ${region.definition.maxZoom}');
}

Use this to show users what they've downloaded and offer delete options.

Check region status

final status = await getOfflineRegionStatus(region.id);
print('Downloaded: ${status.completedResourceCount} tiles');
print('Complete: ${status.isComplete}');

Pause and resume a download

await pauseOfflineRegionDownload(region.id);
// Later, also after the app restarted:
await resumeOfflineRegionDownload(region.id);

A region paused before the app restarted resumes too. No onEvent reports its progress then: read it with getOfflineRegionStatus. A complete region is left as it is.

Delete a region

await deleteOfflineRegion(region.id);

Frees the storage on device. If the user goes offline after deletion, those tiles are no longer available.

Move regions between devices

Offline regions live in a single SQLite database on the device. You can copy that database to another device and merge its regions into the local store, so a region downloaded once can be reused elsewhere without downloading again.

flowchart LR
    A["Device A<br/>download regions"] --> EXP["exportOfflineDatabase(path)<br/>share the .db"]
    EXP --> FILE[(".db file")]
    FILE --> MRG["Device B<br/>mergeOfflineRegions(path)"]
    MRG --> B["Device B<br/>regions available offline"]

    classDef emphasis fill:#1f6feb,stroke:#1a5fd0,color:#fff;
    class A,B emphasis

Export the database

exportOfflineDatabase(destinationPath) writes a copy of the offline database to a location you choose. You pass the full destination file path (folder + file name); the function writes the copy there and returns that same path, or null if nothing has been downloaded yet. It also copies the SQLite -wal/-shm sidecars, so the exported file is consistent.

import 'package:path_provider/path_provider.dart';

Future<String?> exportForSharing() async {
  if (kIsWeb) return null; // offline not supported on web

  final tmp = await getTemporaryDirectory();
  // You decide where the copy goes: here, a temp file called offline_export.db.
  final savedPath = await exportOfflineDatabase('${tmp.path}/offline_export.db');
  // savedPath == '${tmp.path}/offline_export.db', or null if nothing downloaded.
  return savedPath;
}

Then hand savedPath to a share sheet, upload it, or copy it elsewhere.

If you only need the live database's location (for example to copy it yourself), use getOfflineDatabasePath(), which returns its absolute path or null.

Whole-database export

The native SDKs expose no per-region export, so the file contains every region plus the ambient cache, not a single selected region. For the same reason, copy the file while no download is in progress to avoid capturing a half-written database.

Merge a database

mergeOfflineRegions(path) imports every region contained in an external database into the local store and returns the merged regions.

Future<void> importOfflineDatabase(String path) async {
  if (kIsWeb) return; // offline not supported on web

  final List<OfflineRegion> merged = await mergeOfflineRegions(path);
  print('Merged ${merged.length} region(s)');
}

Databases produced by external tools (for example maplibre-native's offline.cpp) can also be merged; regions without Flutter-assigned metadata are imported with an empty metadata map and a stable, derived ID. A file the app can only read, such as one in the app bundle, is merged from a temporary copy.

Storage considerations

  • Tiles are stored in MapLibre's SQLite database on device
  • Android: stored in the app's internal storage
  • Typical sizes: city center at zoom 10–15 ≈ 20–100 MB

Style assets

The offline download includes tiles, fonts, and sprites needed by the style URL. If you later change the style URL, previously downloaded regions may not display correctly.

Limit the ambient cache

The same database also holds the ambient cache: the tiles, fonts and sprites the map keeps from ordinary browsing, so a recently viewed area still renders through a short loss of connectivity. The native SDKs cap it at 50 MB. To pick a different cap, call setMaximumAmbientCacheSize with a size in bytes before the first map is created:

// Keep at most 20 MB of browsed tiles on disk.
await setMaximumAmbientCacheSize(20 * 1024 * 1024);

Offline regions do not count towards the limit and are never evicted by it. Passing 0 disables ambient caching. If the cache is already over the new limit, the SDK trims it immediately, which can take a moment on a large database. Not available on web, which has no ambient cache.

Errors

The offline calls fail with a PlatformException whose code is the same on Android and iOS. The codes of the other calls are in Errors.

Code When
REGION_NOT_FOUND pauseOfflineRegionDownload, resumeOfflineRegionDownload, getOfflineRegionStatus, deleteOfflineRegion or updateOfflineRegionMetadata got an id that no stored region has.
INVALID_ARGUMENT A negative setOfflineTileCountLimit limit, a setOfflineMaxConcurrentRequests value below 1, metadata that jsonEncode cannot encode, or an installOfflineMapTiles path or asset with no file.
INVALID_REGION_DEFINITION downloadOfflineRegion got a definition MapLibre cannot create a region from, such as a mapStyleUrl without a scheme (assets/style.json), NaN bounds, or a zoom range that is not finite, starts below 0 or is inverted.
OFFLINE_DATABASE_ERROR The offline database failed the call, for example on a file mergeOfflineRegions cannot import, or installOfflineMapTiles could not copy its file. The message carries the reason.
AMBIENT_CACHE_ERROR MapLibre failed to clear the ambient cache or to change its size.

A download that stops before it completes calls onEvent with an Error whose cause.code says why:

Code When
INVALID_REGION_DEFINITION MapLibre could not create the region. downloadOfflineRegion fails with the same error.
TILE_COUNT_LIMIT_EXCEEDED The region needs more tiles than the limit set with setOfflineTileCountLimit. The region is deleted.
REGION_DELETED deleteOfflineRegion deleted the region.
REGION_REPLACED A download of the same area replaced the region.
DATABASE_RESET resetOfflineDatabase deleted every region.
try {
  await deleteOfflineRegion(id);
} on PlatformException catch (e) {
  // Deleted already, for example from another screen.
  if (e.code != 'REGION_NOT_FOUND') rethrow;
}

Key APIs

Function Description
downloadOfflineRegion(definition, onEvent) Start a download, get status callbacks
getListOfRegions() List all downloaded regions
getOfflineRegionStatus(id) Check status of a specific region
pauseOfflineRegionDownload(id) / resumeOfflineRegionDownload(id) Pause or resume a download
deleteOfflineRegion(id) Delete a downloaded region
mergeOfflineRegions(path) Import regions from an external database file
exportOfflineDatabase(destPath) Copy the whole offline database to a shareable file
getOfflineDatabasePath() Absolute path of the live offline database file
setMaximumAmbientCacheSize(bytes) Cap the ambient cache; offline regions are not counted