Building Offline-First Field Collection Web Apps with Progressive Web App (PWA) Architecture
Introduction

“Implementing an Offline First Field Collection Web App PWA GIS is essential when field data collection takes place where internet connectivity is unreliable or unavailable. Field data collection frequently takes place where internet connectivity is unreliable or unavailable. Environmental surveys, agricultural assessments, infrastructure inspections, wildlife monitoring, and public health mapping may require collecting geographic coordinates, photographs, observations, and attribute data far beyond reliable cellular coverage.
A conventional web GIS application often depends on an active connection to load its interface, display map tiles, save survey forms, and submit records to a database. If the connection disappears, the workflow can break precisely when the fieldworker needs it most.
A Progressive Web App (PWA) addresses this problem by combining browser-based mapping with persistent client-side storage, cached application resources, and deferred synchronization.
The result is an offline-first field collection web app: a system in which users can capture observations and retain their work locally, regardless of whether the server is currently reachable.
Three browser technologies form the foundation:
-
Service Workers: Intercept network requests and provide cached application resources when connectivity is unavailable.
-
IndexedDB: Persist structured records, including GeoJSON geometries and survey attributes, in the browser.
-
Web App Manifest: Defines installation-related metadata such as the app name, icons, display mode, and startup URL.
Service workers provide the network interception and caching mechanisms that make offline application shells possible, while IndexedDB supports asynchronous storage of structured data.
In this tutorial, we will develop a field collection application using Leaflet, LocalForage, a custom service worker, and an API that synchronizes observations with a PostgreSQL/PostGIS backend.
By the end, the application will support:
-
A Leaflet map with browser GPS tracking.
-
Offline access to the application interface and a custom vector layer.
-
Local storage of survey observations and their geometries.
-
An optional downloadable offline map tile package.
-
Automatic synchronization when connectivity returns.
-
A server-side API for safely persisting records in PostGIS.
One distinction is essential: offline form collection, offline application access, and offline basemap access are separate capabilities. Caching the application does not automatically make an online map provider’s tiles available offline.
1. Understand the PWA Architecture
A reliable offline-first GIS should separate data capture, local persistence, map resources, and server synchronization.
Fieldworker’s browser
Leaflet map · GPS · Attribute forms
Store first, synchronize later
IndexedDB / LocalForage
Field observations, geometry, status, and retry queue.
Cache Storage
Application assets, approved map tiles, and cached resources.
When connectivity becomes available
Server API → PostgreSQL / PostGIS
Validation · Authentication · Duplicate-safe writes
1.1 Service Worker
A service worker runs separately from the page and can intercept requests initiated by controlled pages. It can serve cached resources when the network request cannot succeed. It requires a secure context, normally HTTPS, with localhost treated as secure for development.
Use it to manage application assets and approved offline map resources—not to store the survey database itself.
1.2 IndexedDB and LocalForage
IndexedDB is the browser’s asynchronous database for structured client-side data. LocalForage provides a simpler key-value interface and normally uses IndexedDB when it is available.
Store each field observation as an individual record. This makes retrying and updating synchronization status easier than repeatedly rewriting a single large array.
1.3 Web App Manifest
The manifest describes how the browser should present the installed application. It does not itself provide offline caching or persistence.
1.4 Synchronization queue
Every new observation is initially stored locally with a status such as pending.
When connectivity returns, the application submits pending records to the API. After the server accepts a record, the local copy is marked synced.
A production workflow should use stable record IDs and server-side idempotency so that a request retried after a timeout does not create a duplicate observation.
2. Set Up the Development Environment
We will use Vite to bundle the application and vite-plugin-pwa to build the custom service worker with a generated precache manifest.
Install a current Node.js release with npm, then run:
# Create a vanilla JavaScript project
npm create vite@latest field-collector -- --template vanilla
# Enter the project
cd field-collector
# Install the starter dependencies
npm install
# Install GIS and local storage libraries
npm install leaflet localforage
# Install the PWA build plugin
npm install -D vite-plugin-pwa
Leaflet provides the interactive map, LocalForage handles browser persistence, and the PWA plugin bundles the custom service worker for production.
Create the following structure:
field-collector/
├── index.html
├── vite.config.js
├── public/
│ ├── data/
│ │ └── field-zones.geojson
│ └── icons/
│ ├── icon-192.png
│ └── icon-512.png
└── src/
├── main.js
├── outbox.js
├── style.css
└── sw.js
The public directory holds static resources that Vite copies into the production build. Files under src contain the application, local data-access layer, and custom service worker.
3. Configure the PWA Build
Create vite.config.js:
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
VitePWA({
// Use our own service worker.
strategies: "injectManifest",
// Location and output name of the custom service worker.
srcDir: "src",
filename: "sw.js",
// Precache the application shell and small static resources.
injectManifest: {
globPatterns: [
"**/*.{html,js,css,svg,png,ico,webmanifest,json}"
],
// Large map packages are downloaded selectively instead.
globIgnores: [
"**/offline-tiles/**"
],
// Prevent unexpectedly large resources entering the app shell.
maximumFileSizeToCacheInBytes: 2 * 1024 * 1024
},
// Define the installation experience.
manifest: {
name: "Offline Field Collector",
short_name: "Field Collector",
description: "Offline-first geographic field data collection.",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#f8fafc",
theme_color: "#174b3a",
icons: [
{
src: "/icons/icon-192.png",
sizes: "192x192",
type: "image/png"
},
{
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png"
}
]
},
// Test the production-built worker rather than relying on
// development-server behavior for offline verification.
devOptions: {
enabled: false
}
})
]
});
Why use injectManifest?
A hand-written service worker that caches only index.html might fail when the JavaScript and CSS files are not cached. Production build tools often generate filenames that change between releases.
The injectManifest approach lets the build tool generate the asset list while retaining custom code for tile packages and background synchronization.
The small resource size limit helps keep the initial application cache under control. Large operational datasets and map packages should be handled through a separate download workflow.
4. Create the HTML Interface
Replace index.html with:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<meta name="theme-color" content="#174b3a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Offline Field Collector</title>
</head>
<body>
<header class="app-header">
<div>
<h1>Field Collector</h1>
<p>Capture observations offline</p>
</div>
<span id="app-status" role="status" aria-live="polite">
Initializing…
</span>
</header>
<section class="toolbar">
<button id="gps-start" type="button">
Start GPS
</button>
<button id="gps-stop" type="button" disabled>
Stop GPS
</button>
<button id="prepare-tiles" type="button">
Prepare offline map
</button>
<button id="sync-now" type="button">
Sync now
</button>
</section>
<section class="survey-panel">
<form id="entry-form">
<label>
Observation category
<select name="category" required>
<option value="vegetation">Vegetation</option>
<option value="water">Water</option>
<option value="infrastructure">Infrastructure</option>
<option value="other">Other</option>
</select>
</label>
<label>
Field notes
<textarea
name="notes"
rows="2"
maxlength="5000"
placeholder="Describe the observation"
></textarea>
</label>
<button type="submit">
Save observation
</button>
</form>
<p id="position-status">
Select a location on the map or obtain a GPS fix.
</p>
<p id="queue-status">No records loaded.</p>
<ul id="record-list" aria-label="Saved observations"></ul>
</section>
<main id="map" aria-label="Field collection map"></main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
This interface contains the necessary controls for GPS tracking, survey capture, offline tile preparation, and synchronization.
The map-click workflow is included as a fallback. GPS may be unavailable indoors or under heavy canopy, and users should be able to place a point manually when their field protocol permits it.
5. Build the Leaflet Map Interface
Create src/style.css:
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
font-family: system-ui, sans-serif;
color: #172033;
background: #f8fafc;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
button,
select,
textarea {
font: inherit;
}
button {
padding: 9px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 18px;
background: #174b3a;
color: #ffffff;
}
.app-header h1,
.app-header p {
margin: 0;
}
.app-header h1 {
font-size: 1.2rem;
}
.app-header p {
margin-top: 4px;
font-size: 0.85rem;
}
#app-status {
font-size: 0.85rem;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 14px;
background: #e8efe9;
}
.survey-panel {
padding: 12px 14px;
background: #ffffff;
border-bottom: 1px solid #dbe2ea;
}
#entry-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: end;
}
#entry-form label {
display: grid;
gap: 5px;
flex: 1 1 180px;
font-size: 0.85rem;
}
textarea,
select {
min-width: 0;
width: 100%;
padding: 8px;
border: 1px solid #cbd5e1;
border-radius: 5px;
}
#position-status,
#queue-status {
margin: 8px 0 0;
font-size: 0.85rem;
}
#record-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-left: 20px;
margin: 8px 0 0;
font-size: 0.8rem;
}
#map {
flex: 1;
min-height: 320px;
}
@media (max-width: 620px) {
.app-header {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
#entry-form {
align-items: stretch;
flex-direction: column;
}
}
The map takes the remaining vertical space, while the capture interface stays accessible above it. This is a practical layout for field devices where surveyors need to move between the map and the form.
5.1 Prepare a local vector layer
Create public/data/field-zones.geojson:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"zone": "A",
"name": "Sample survey zone"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[36.810, -1.280],
[36.825, -1.280],
[36.825, -1.295],
[36.810, -1.295],
[36.810, -1.280]
]
]
}
}
]
}
This is a sample study polygon near Nairobi, not an authoritative boundary. Replace it with a GeoJSON layer for your project, such as farm boundaries, inspection zones, watershed units, or survey plots.
Because this is a same-origin local data asset, the application can precache it along with its other small resources.
6. Store Field Observations with LocalForage
The application should persist an observation before trying to contact the server.
That ordering is the core of an offline-first system:
-
Validate the form.
-
Build the GeoJSON feature and its metadata.
-
Save it in IndexedDB.
-
Refresh the local queue.
-
Attempt synchronization when the network is available.
6.1 Create the local database and synchronization logic
Create src/outbox.js:
import localforage from "localforage";
// A named IndexedDB-backed store shared by the app and service worker.
export const outbox = localforage.createInstance({
name: "FieldCollectorPWA",
storeName: "field_entries",
description: "Locally captured field observations"
});
const API_ENDPOINT = "/api/observations";
// Return all locally stored field records.
export async function listFieldEntries() {
const records = [];
await outbox.iterate((value, key) => {
if (String(key).startsWith("entry:")) {
records.push(value);
}
});
// Newest observations appear first.
return records.sort((a, b) => {
return b.properties.captured_at.localeCompare(
a.properties.captured_at
);
});
}
// Submit pending entries to the server.
// The server must enforce uniqueness for the record ID.
export async function syncPendingEntries() {
const entries = [];
await outbox.iterate((value, key) => {
if (
String(key).startsWith("entry:") &&
value.syncStatus !== "synced" &&
value.syncStatus !== "needs_review"
) {
entries.push({ key, value });
}
});
entries.sort((a, b) => {
return a.value.properties.captured_at.localeCompare(
b.value.properties.captured_at
);
});
let sent = 0;
for (const { key, value } of entries) {
try {
const response = await fetch(API_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": value.id
},
credentials: "same-origin",
body: JSON.stringify(value)
});
// Most 4xx responses require user or data correction.
// HTTP 408 and 429 are treated as retryable.
if (
response.status >= 400 &&
response.status < 500 &&
![408, 429].includes(response.status)
) {
const current = await outbox.getItem(key);
if (current && current.syncStatus !== "synced") {
await outbox.setItem(key, {
...current,
syncStatus: "needs_review",
lastSyncError: `HTTP ${response.status}`,
lastAttemptAt: new Date().toISOString()
});
}
continue;
}
// Stop the current batch on a transient server failure.
if (!response.ok) {
throw new Error(`Server returned HTTP ${response.status}`);
}
// Re-read the current record to avoid overwriting a successful
// update made by another tab or a concurrent service worker.
const current = await outbox.getItem(key);
if (current && current.syncStatus !== "synced") {
await outbox.setItem(key, {
...current,
syncStatus: "synced",
syncedAt: new Date().toISOString(),
lastSyncError: null
});
}
sent += 1;
} catch (error) {
const current = await outbox.getItem(key);
if (current && current.syncStatus !== "synced") {
await outbox.setItem(key, {
...current,
syncStatus: "pending",
syncAttempts: (current.syncAttempts ?? 0) + 1,
lastAttemptAt: new Date().toISOString(),
lastSyncError: String(error.message ?? error)
});
}
// Stop so the remaining records can be retried later.
// In a service-worker sync event, the rejection also allows
// the browser to retry the background synchronization task.
throw new Error(
`Synchronization stopped after ${sent} successful records: ${error.message}`
);
}
}
return { sent, processed: entries.length };
}
Why use one record per key?
Each entry is stored using a key such as entry:550e8400-e29b-41d4-a716-446655440000.
That lets the app update a record’s synchronization state without replacing the entire collection. LocalForage supports promise-based getItem(), setItem(), and iteration operations.
The needs_review status distinguishes records that require correction or authentication from records that are safe to retry automatically.
The Idempotency-Key header is only a request hint. The backend must actually enforce duplicate protection, typically through a unique primary key and an idempotent database operation.
7. Initialize Leaflet and Load GPS Coordinates
Now create src/main.js.
7.1 Imports and map initialization
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "./style.css";
import {
outbox,
listFieldEntries,
syncPendingEntries
} from "./outbox.js";
// UI elements
const statusElement = document.getElementById("app-status");
const positionStatus = document.getElementById("position-status");
const queueStatus = document.getElementById("queue-status");
const recordList = document.getElementById("record-list");
const entryForm = document.getElementById("entry-form");
const gpsStartButton = document.getElementById("gps-start");
const gpsStopButton = document.getElementById("gps-stop");
const prepareTilesButton = document.getElementById("prepare-tiles");
const syncNowButton = document.getElementById("sync-now");
// This setting must be changed to true when a licensed,
// same-origin offline tile package is available.
const USE_LOCAL_TILE_PACKAGE = false;
// Initialize a Leaflet map centered on the sample survey area.
const map = L.map("map").setView([-1.2864, 36.8172], 13);
// Online-only demonstration basemap.
// Its tiles are deliberately not precached by this application.
if (USE_LOCAL_TILE_PACKAGE) {
L.tileLayer("/offline-tiles/{z}/{x}/{y}.png", {
minZoom: 8,
maxZoom: 18,
attribution: "Attribution for your licensed tile source"
}).addTo(map);
} else {
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap contributors"
}).addTo(map);
}
// Keep GPS selection separate from map display and database storage.
let selectedLocation = null;
let locationMarker = null;
let gpsWatchId = null;
let hasCenteredOnGps = false;
function setStatus(message) {
statusElement.textContent = message;
}
The initial map uses an online OpenStreetMap basemap for the demonstration. The local-tile branch is intentionally configurable because you must prepare a permitted offline map package before it can render a local basemap.
7.2 Set the selected location
Add:
function setSelectedLocation(latlng, accuracyM = null, source = "map") {
selectedLocation = {
lat: latlng.lat,
lng: latlng.lng,
accuracyM,
source,
capturedAt: new Date().toISOString()
};
// Create the marker once and update it on subsequent fixes.
if (!locationMarker) {
locationMarker = L.circleMarker(latlng, {
radius: 8,
color: "#ffffff",
weight: 2,
fillColor: "#0d8f64",
fillOpacity: 1
}).addTo(map);
} else {
locationMarker.setLatLng(latlng);
}
const accuracyText = Number.isFinite(accuracyM)
? ` · estimated accuracy ${Math.round(accuracyM)} m`
: "";
positionStatus.textContent =
`${source === "gps" ? "GPS position" : "Selected position"}: ` +
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}` +
accuracyText;
}
// Allow manual point selection as a GPS fallback.
map.on("click", (event) => {
setSelectedLocation(event.latlng, null, "map");
});
The selected point is held in application memory until the user saves an observation. The map-click handler gives users a manual fallback when GPS is unavailable.
7.3 Track GPS using navigator.geolocation
Add:
function startGpsTracking() {
// Geolocation normally requires HTTPS or localhost and user permission.
if (!("geolocation" in navigator)) {
setStatus("This browser does not support geolocation.");
return;
}
if (gpsWatchId !== null) {
return;
}
gpsWatchId = navigator.geolocation.watchPosition(
(position) => {
const {
latitude,
longitude,
accuracy
} = position.coords;
const latlng = L.latLng(latitude, longitude);
// Update the currently selected field location.
setSelectedLocation(latlng, accuracy, "gps");
// Center on the first fix without forcing the map to jump
// on every later GPS update.
if (!hasCenteredOnGps) {
map.setView(latlng, 17);
hasCenteredOnGps = true;
}
setStatus("GPS position updated.");
},
(error) => {
const messages = {
1: "Location permission was denied.",
2: "The position is unavailable.",
3: "The GPS request timed out."
};
setStatus(messages[error.code] ?? error.message);
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 5000
}
);
gpsStartButton.disabled = true;
gpsStopButton.disabled = false;
setStatus("Waiting for GPS position…");
}
function stopGpsTracking() {
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
gpsStartButton.disabled = false;
gpsStopButton.disabled = true;
setStatus("GPS tracking stopped.");
}
gpsStartButton.addEventListener("click", startGpsTracking);
gpsStopButton.addEventListener("click", stopGpsTracking);
navigator.geolocation.watchPosition() requests successive position updates. Leaflet’s own map.locate({ watch: true }) is another option, but using the browser API directly makes the GPS acquisition logic explicit.
Field accuracy matters: the reported accuracy value estimates horizontal uncertainty in meters. Save it with the observation and define a project-specific rule for accepting or rejecting low-quality fixes.
GPS reception does not inherently require cellular connectivity, but permission settings, hardware, satellite visibility, and device location services affect whether a fix can be obtained and how quickly it arrives.
8. Load the Custom Vector Layer Offline
The survey zones are static GeoJSON resources, so we can load them from the same origin as the application.
Add the following code to main.js:
async function loadFieldZones() {
const response = await fetch("/data/field-zones.geojson");
if (!response.ok) {
throw new Error(
`Unable to load field zones: HTTP ${response.status}`
);
}
const geojson = await response.json();
// Convert GeoJSON features into Leaflet layers.
const zoneLayer = L.geoJSON(geojson, {
style: {
color: "#147d53",
weight: 2,
fillColor: "#38a169",
fillOpacity: 0.2
},
onEachFeature: (feature, layer) => {
const zoneName = feature.properties?.name ?? "Unnamed zone";
layer.bindTooltip(String(zoneName));
}
});
zoneLayer.addTo(map);
}
// Report failures without taking down data capture.
loadFieldZones().catch((error) => {
console.error(error);
setStatus("Survey zones unavailable; local capture remains usable.");
});
Because the build configuration precaches small GeoJSON files, this layer can be available offline after the first successful application installation and precache. You can similarly package inspection points, assigned survey plots, restricted areas, or planned field routes.
For frequently updated operational data, add a versioning and refresh strategy. A precached GeoJSON file is not automatically refreshed just because someone changes it on the server.
9. Save the Form as a GeoJSON Observation
A field observation should capture both its geometry and its properties.
For this implementation, the stored record is a GeoJSON Feature with a Point geometry. Line and polygon capture can be added later using Leaflet drawing tools; those geometries can be persisted in the same general record structure.
Add the following to main.js:
function createObservation(formData) {
if (!selectedLocation) {
throw new Error(
"Select a map location or obtain a GPS fix before saving."
);
}
const now = new Date().toISOString();
return {
type: "Feature",
// Stable local ID, also used as the server idempotency key.
id: crypto.randomUUID(),
// GeoJSON always stores coordinate arrays as [longitude, latitude].
geometry: {
type: "Point",
coordinates: [
selectedLocation.lng,
selectedLocation.lat
]
},
properties: {
category: String(formData.get("category")),
notes: String(formData.get("notes") ?? "").trim(),
accuracy_m: selectedLocation.accuracyM,
location_source: selectedLocation.source,
captured_at: selectedLocation.capturedAt ?? now,
client_created_at: now
},
// Synchronization metadata stays outside the feature properties.
syncStatus: "pending",
syncAttempts: 0,
createdAt: now,
lastSyncError: null
};
}
entryForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const formData = new FormData(entryForm);
const observation = createObservation(formData);
// Persist the observation before attempting any API request.
await outbox.setItem(
`entry:${observation.id}`,
observation
);
setStatus("Observation saved locally.");
// Reset the notes while leaving the category selected.
entryForm.elements.notes.value = "";
await refreshRecordList();
// Queue a server synchronization attempt when possible.
await scheduleSync();
} catch (error) {
console.error("Could not save observation:", error);
setStatus(error.message ?? "Failed to save observation.");
}
});
Why persist before syncing?
If the network fails, the record is already safely stored in the browser database. The survey does not depend on the success of a POST request.
The record also contains a stable ID. If the server commits an observation but the response is lost, the app may resend it; the server-side uniqueness constraint prevents the retry from inserting a second copy.
10. Build the Service Worker
Create src/sw.js.
This worker handles three tasks:
-
Precache the application shell.
-
Cache approved same-origin offline tiles.
-
Process background synchronization requests where the browser supports the Background Synchronization API.
import {
precacheAndRoute,
cleanupOutdatedCaches
} from "workbox-precaching";
import { syncPendingEntries } from "./outbox.js";
// Injected by vite-plugin-pwa during the production build.
// Includes the application's generated HTML, JS, CSS, and small data assets.
precacheAndRoute(self.__WB_MANIFEST);
// Clean up outdated Workbox precaches after application updates.
cleanupOutdatedCaches();
const TILE_CACHE = "field-offline-tiles-v1";
// Intercept only GET requests for our own offline tile package.
// Third-party basemaps are intentionally excluded.
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
if (
request.method !== "GET" ||
url.origin !== self.location.origin ||
!url.pathname.startsWith("/offline-tiles/")
) {
return;
}
event.respondWith(
(async () => {
const cache = await caches.open(TILE_CACHE);
// Prefer a prepared or previously cached local tile.
const cached = await cache.match(request);
if (cached) {
return cached;
}
// Online fallback: fetch and cache only same-origin package files.
try {
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
}
return response;
} catch {
// Do not pretend a missing tile was successfully rendered.
return new Response("Tile unavailable offline.", {
status: 503,
headers: {
"Content-Type": "text/plain"
}
});
}
})()
);
});
// Enhanced synchronization in browsers supporting Background Sync.
// The shared module accesses the same IndexedDB-backed LocalForage store.
self.addEventListener("sync", (event) => {
if (event.tag === "sync-observations") {
event.waitUntil(syncPendingEntries());
}
});
10.1 Understand the caching logic
precacheAndRoute() is responsible for the application shell and the static assets listed by the build tool.
The separate tile handler is cache-first. If a tile exists in the approved local package cache, the worker returns it without making a network request. Otherwise, it attempts to retrieve the same-origin tile and save a copy.
This creates two resource lifecycles:
| Resource | Storage mechanism | Update strategy |
|---|---|---|
| HTML, bundled JS and CSS | Workbox precache | Build version and deployment |
| Small local GeoJSON | Workbox precache | Explicit application version/update |
| Approved offline tiles | Dedicated Cache Storage cache | User-selected package download/update |
| Survey observations | IndexedDB via LocalForage | Record-level synchronization status |
Do not put all collected records in Cache Storage. Cache Storage is intended for request-response resources, while IndexedDB is the suitable place for structured survey records.
11. Prepare an Offline Map Tile Package
Caching previously viewed map tiles does not guarantee that a whole survey region will be available offline. A fieldworker may need to pan or zoom into areas they have never visited online.
The app therefore has a Prepare offline map button. It downloads a curated package of same-origin tile URLs into the dedicated cache.
First, prepare a tile package using a provider or data pipeline that explicitly permits offline storage and use. Host the permitted files under:
public/
└── offline-tiles/
├── manifest.json
└── z/
└── x/
└── y.png
For a raster package in standard XYZ layout, paths generally follow {z}/{x}/{y}.png. Ensure the selected Leaflet URL pattern matches the actual package structure.
Create public/offline-tiles/manifest.json containing the exact tile paths prepared for your survey area:
{
"tiles": [
"/offline-tiles/12/1205/1538.png",
"/offline-tiles/12/1205/1539.png",
"/offline-tiles/12/1206/1538.png"
]
}
These paths are illustrative. They must correspond to actual tiles in the package.
Important tile licensing limitation
Do not use the standard OpenStreetMap raster tile server as a bulk-download source for offline packages. Its usage policy prohibits bulk downloading and prefetching tiles. Use a provider with explicit offline rights or a tile service you operate yourself, while complying with the required attribution and licensing terms.
11.1 Implement tile package download
Add the following function to main.js:
async function prepareOfflineTilePackage() {
prepareTilesButton.disabled = true;
try {
setStatus("Preparing offline map tiles…");
// This manifest describes the package for the current study area.
const response = await fetch("/offline-tiles/manifest.json");
if (!response.ok) {
throw new Error(
"Tile manifest unavailable. Prepare a licensed tile package first."
);
}
const manifest = await response.json();
if (!Array.isArray(manifest.tiles) || manifest.tiles.length === 0) {
throw new Error("The tile package contains no tile paths.");
}
// Cache key must match the service worker's dedicated tile cache.
const cache = await caches.open("field-offline-tiles-v1");
const tilePaths = [...new Set(manifest.tiles)];
let completed = 0;
// Small batches limit concurrency and make progress visible.
const batchSize = 30;
for (let i = 0; i < tilePaths.length; i += batchSize) {
const batch = tilePaths.slice(i, i + batchSize);
await Promise.all(
batch.map(async (path) => {
const url = new URL(path, window.location.origin);
// Only same-origin offline package resources are accepted.
if (
url.origin !== window.location.origin ||
!url.pathname.startsWith("/offline-tiles/")
) {
throw new Error(`Invalid tile package path: ${path}`);
}
// Skip tiles already downloaded.
if (!(await cache.match(url.href))) {
await cache.add(url.href);
}
completed += 1;
})
);
setStatus(
`Offline tiles: ${completed} / ${tilePaths.length}`
);
}
setStatus(`Prepared ${completed} offline map tiles.`);
} catch (error) {
console.error(error);
setStatus(error.message ?? "Offline tile preparation failed.");
} finally {
prepareTilesButton.disabled = false;
}
}
prepareTilesButton.addEventListener(
"click",
prepareOfflineTilePackage
);
This implementation uses batches rather than launching thousands of simultaneous requests. You should also validate package size, monitor available storage, and offer a delete/update workflow in a production application.
After the tile package is hosted, change:
const USE_LOCAL_TILE_PACKAGE = false;
to:
const USE_LOCAL_TILE_PACKAGE = true;
The Leaflet map will then request the local tiles instead of the online OpenStreetMap basemap.
Operational note: A large package should not necessarily be downloaded as one massive batch. For production, consider a package manifest that describes multiple survey regions, zoom ranges, file sizes, and version numbers. Provide a download progress indicator and let users select exactly the areas required for the assignment.
12. Refresh the Local Queue and Synchronize Automatically
The next step connects LocalForage to the user interface.
Add these functions to main.js:
async function refreshRecordList() {
const records = await listFieldEntries();
const pending = records.filter(
(record) => record.syncStatus === "pending"
);
const needsReview = records.filter(
(record) => record.syncStatus === "needs_review"
);
const synced = records.filter(
(record) => record.syncStatus === "synced"
);
queueStatus.textContent =
`${pending.length} pending · ` +
`${synced.length} synced · ` +
`${needsReview.length} need review`;
// Rebuild the visible list without injecting survey notes as HTML.
recordList.replaceChildren();
for (const record of records.slice(0, 15)) {
const item = document.createElement("li");
const category = record.properties.category;
const status = record.syncStatus;
item.textContent =
`${category} — ${status} — ${record.id.slice(0, 8)}`;
recordList.appendChild(item);
}
}
Displaying records with textContent avoids treating user-entered notes or attributes as HTML.
12.1 Add the automatic synchronization function
let foregroundSyncRunning = false;
async function scheduleSync(forceForeground = false) {
const records = await listFieldEntries();
const pending = records.filter(
(record) =>
record.syncStatus === "pending" ||
!record.syncStatus
);
if (pending.length === 0) {
setStatus("No records awaiting synchronization.");
await refreshRecordList();
return;
}
if (!navigator.onLine) {
setStatus("Offline: observations remain stored locally.");
return;
}
// Avoid overlapping foreground attempts in this page.
if (foregroundSyncRunning) {
return;
}
try {
// Background Sync is an enhancement, not a universal guarantee.
if (!forceForeground && "serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.ready;
if (registration.sync?.register) {
await registration.sync.register("sync-observations");
setStatus("Synchronization queued for the background.");
return;
}
}
} catch (error) {
console.warn("Background Sync unavailable:", error);
}
// Fallback for browsers without Background Sync or when the user
// explicitly requests immediate synchronization.
foregroundSyncRunning = true;
try {
const report = await syncPendingEntries();
setStatus(
`Synchronization complete: ${report.sent} records submitted.`
);
} catch (error) {
setStatus(
`Synchronization incomplete. Records remain queued: ${error.message}`
);
} finally {
foregroundSyncRunning = false;
await refreshRecordList();
}
}
// Manual retry
syncNowButton.addEventListener("click", () => {
void scheduleSync(true);
});
// Network changes: use them as hints to retry.
window.addEventListener("online", () => {
void scheduleSync();
});
window.addEventListener("offline", () => {
setStatus("Offline: local data capture is still available.");
});
// Retry when the user returns to the application.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
void scheduleSync(true);
}
});
// Register the custom PWA worker in production.
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", async () => {
try {
await navigator.serviceWorker.register("/sw.js");
setStatus("Application ready.");
} catch (error) {
console.error("Service Worker registration failed:", error);
setStatus("Offline application caching could not be enabled.");
}
});
}
// Load the local queue on startup.
refreshRecordList().catch(console.error);
// Attempt recovery when the application starts.
window.addEventListener("load", () => {
void scheduleSync();
});
What triggers synchronization?
The application tries to synchronize when:
-
A new observation has been saved.
-
The browser reports that it is online.
-
The user returns to the application.
-
The user presses Sync now.
-
The browser runs a registered Background Sync task, where supported.
The online event and navigator.onLine are useful signals, but they cannot prove that your API is reachable. A device may have a network connection while the server is down or a firewall blocks access. The actual API request and its response determine whether synchronization succeeded.
Background Sync limitations
The foreground retry mechanisms remain important: they work when the user has the application open or reopens it. Do not promise that every browser will continue synchronizing records in the background after a user closes the app.
13. Connect the App to PostGIS
So far, the application stores observations in IndexedDB. To make them available to other users and systems, we need a server-side API.
We will use Node.js, Express, pg, and PostgreSQL/PostGIS. You can adapt the API contract for a GeoJSON service instead.
13.1 Create a PostGIS table
First, enable PostGIS in your database:
CREATE EXTENSION IF NOT EXISTS postgis;
Create the observations table:
CREATE TABLE field_observations (
id UUID PRIMARY KEY,
category TEXT NOT NULL,
notes TEXT,
captured_at TIMESTAMPTZ NOT NULL,
accuracy_m DOUBLE PRECISION,
geom geometry(Point, 4326) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Spatial index for future mapping and spatial queries.
CREATE INDEX field_observations_geom_gix
ON field_observations
USING GIST (geom);
The UUID primary key supports idempotent writes. The geometry(Point, 4326) column stores longitude/latitude point geometries in the WGS 84 reference system.
For projects requiring line or polygon surveys, consider a separate table or a suitable generic geometry column with validation for allowed geometry types.
13.2 Implement an Express endpoint
Install backend dependencies in your server project:
npm install express pg
The following is a minimal example of an insertion endpoint:
import express from "express";
import pg from "pg";
const app = express();
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL
});
app.use(express.json({ limit: "1mb" }));
// Add real authentication middleware before exposing this endpoint.
// For example: app.post("/api/observations", requireAuth, handler);
app.post("/api/observations", async (req, res, next) => {
try {
const feature = req.body;
// Validate the basic GeoJSON and the expected point geometry.
if (
feature?.type !== "Feature" ||
feature?.geometry?.type !== "Point" ||
!Array.isArray(feature.geometry.coordinates)
) {
return res.status(422).json({
error: "A GeoJSON Point feature is required."
});
}
const [longitude, latitude] = feature.geometry.coordinates;
const properties = feature.properties ?? {};
if (
!Number.isFinite(longitude) ||
!Number.isFinite(latitude) ||
longitude < -180 ||
longitude > 180 ||
latitude < -90 ||
latitude > 90
) {
return res.status(422).json({
error: "Invalid longitude or latitude."
});
}
if (
typeof feature.id !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(feature.id)
) {
return res.status(422).json({
error: "A valid UUID is required."
});
}
const category = String(properties.category ?? "").trim();
const notes = String(properties.notes ?? "");
const capturedAt = properties.captured_at;
const accuracy = properties.accuracy_m;
const parsedDate = new Date(capturedAt);
if (!category || Number.isNaN(parsedDate.getTime())) {
return res.status(422).json({
error: "Category and a valid capture timestamp are required."
});
}
// Check the declared idempotency key against the body ID.
const idempotencyKey = req.get("Idempotency-Key");
if (idempotencyKey && idempotencyKey !== feature.id) {
return res.status(422).json({
error: "Idempotency-Key does not match the observation ID."
});
}
// Parameterized SQL prevents the values from being interpreted as SQL.
await pool.query(
`
INSERT INTO field_observations (
id,
category,
notes,
captured_at,
accuracy_m,
geom
)
VALUES (
$1,
$2,
$3,
$4,
$5,
ST_SetSRID(ST_MakePoint($6, $7), 4326)
)
ON CONFLICT (id) DO NOTHING
`,
[
feature.id,
category,
notes,
parsedDate.toISOString(),
Number.isFinite(accuracy) ? accuracy : null,
longitude,
latitude
]
);
// Repeated submissions with the same UUID do not create a duplicate.
return res.status(200).json({
id: feature.id,
accepted: true
});
} catch (error) {
next(error);
}
});
app.listen(3000, () => {
console.log("Field API listening on port 3000");
});
This API validates essential input, uses parameterized SQL, stores geometries with ST_MakePoint(), and enforces uniqueness with the primary key.
Production requirement: add authentication, authorization, rate limiting, payload-size limits, server-side project/user assignment, audit logging, and stronger validation. The example route is an integration pattern—not a complete access-control system. Deploy it behind HTTPS and use a secure session or token design appropriate to your application.
13.3 Why idempotency matters
Consider this sequence:
-
The browser sends an observation.
-
PostGIS successfully inserts it.
-
The response fails to reach the device.
-
The browser considers the entry pending and retries.
-
The database recognizes the existing primary key and does not insert it again.
The application can then mark the local record as synchronized after receiving the successful response.
If the server requires additional edits or merges rather than ignoring duplicates, implement that policy explicitly. For multi-user editing, a conflict-resolution model with version metadata is safer than allowing a last-write-wins update without review.
14. Test the Offline Workflow
Do not test only by refreshing the page while connected. Test the full field lifecycle.
14.1 Build and serve the production bundle
Build the application:
npm run build
Then use Vite’s preview server:
npm run preview
Open the preview URL in the browser. The custom service worker is configured for the production build.
For local testing, localhost qualifies as a secure context for service workers. Production must be served over HTTPS.
14.2 Run the field collection test
0 of 9 verification steps completed
Use browser developer tools to simulate offline connectivity, inspect IndexedDB records, and view Cache Storage. Test a real mobile device as well: simulator behavior cannot fully reproduce GPS reception, battery constraints, storage pressure, and app lifecycle behavior in the field.
15. Important Production Considerations
15.1 Browser storage is not a server backup
IndexedDB is persistent browser storage, but web storage can be affected by device storage pressure, user clearing site data, private browsing restrictions, or browser policies.
For a field deployment, consider requesting persistent storage where available:
async function requestPersistentStorage() {
if (!navigator.storage?.persist) {
return false;
}
return navigator.storage.persist();
}
A successful request can reduce the risk of automatic eviction in supporting browsers, but it is not a substitute for syncing. Exportable backups or project-specific recovery procedures may be necessary for high-value surveys.
15.2 IndexedDB storage is not automatically a spatial database
The example stores GeoJSON in IndexedDB; it does not create an R-tree or other spatial index.
For thousands of ordinary field observations, this simple model may be sufficient. For extensive offline datasets with repeated spatial filtering, use an appropriate spatial indexing strategy or a browser-side spatial query library rather than iterating over every record for every query.
15.3 Do not rely on cached external tiles for complete offline coverage
Browsers may retain resources from previous visits, but cache eviction and tile availability are not predictable enough for field operations that depend on a complete region.
Use a defined, legally acquired offline package when full map coverage is required. The application should show which region and zoom levels have actually been prepared.
15.4 Treat sync as a durable queue
Each record should have a unique ID, creation timestamp, retry state, and last error. The API should validate records and enforce uniqueness. The UI should visibly distinguish:
-
Pending: stored locally and awaiting submission.
-
Synced: accepted by the server.
-
Needs review: rejected or blocked until a user or administrator resolves the problem.
Do not delete local records immediately after a network request is sent. Retain them until successful server acceptance, and define when synced records can be archived or cleared.
15.5 Handle map, GPS, and data updates separately
GPS fixes, form submission, and server synchronization are separate events. Updating one should not require recreating the Leaflet map or downloading all the survey data again.
Likewise, avoid storing credentials or sensitive authentication tokens in a plain survey record. Use an appropriate authentication flow and protect sensitive field data on shared devices.

Summary
An offline-first field collection PWA is built around a simple principle: capture and persist locally first; synchronize with the server when connectivity permits.
The main components work together as follows:
| Component | Responsibility |
|---|---|
| Leaflet | Interactive map, survey context, and GPS marker visualization |
navigator.geolocation |
Obtaining location updates when permitted by the browser and device |
| Service Worker | Serving cached application assets and approved offline map resources |
| Web App Manifest | Defining the application’s installation metadata |
| IndexedDB / LocalForage | Persisting observations, attributes, geometries, and sync status |
| Background Sync / online retry | Triggering deferred synchronization where possible |
| Express API | Validating and receiving observations |
| PostgreSQL/PostGIS | Durable spatial storage, indexing, and analysis |
A good development sequence is to make local collection reliable first, add the service worker and static offline resources second, package the required map regions third, and then implement synchronization with a duplicate-safe API.
The final test is not simply whether the map opens offline. It is whether a fieldworker can obtain or select a location, record a valid observation, close or reload the application, retain that observation, and later synchronize it without losing or duplicating data.