{"id":1376,"date":"2026-10-23T07:06:04","date_gmt":"2026-10-23T07:06:04","guid":{"rendered":"https:\/\/helpwithmygisassignment.com\/blog\/?p=1376"},"modified":"2026-10-23T07:06:04","modified_gmt":"2026-10-23T07:06:04","slug":"learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers","status":"publish","type":"post","link":"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/","title":{"rendered":"Building Offline-First Field Collection Web Apps with Progressive Web App (PWA) Architecture"},"content":{"rendered":"<div><\/div>\n<div>\n<div id=\"ez-toc-container\" class=\"ez-toc-v2_0_89 counter-hierarchy ez-toc-counter ez-toc-grey ez-toc-container-direction\">\n<div class=\"ez-toc-title-container\">\n<p class=\"ez-toc-title\" style=\"cursor:inherit\">Table of Contents<\/p>\n<span class=\"ez-toc-title-toggle\"><a href=\"#\" class=\"ez-toc-pull-right ez-toc-btn ez-toc-btn-xs ez-toc-btn-default ez-toc-toggle\" aria-label=\"Toggle Table of Content\"><span class=\"ez-toc-js-icon-con\"><span class=\"\"><span class=\"eztoc-hide\" style=\"display:none;\">Toggle<\/span><span class=\"ez-toc-icon-toggle-span\"><svg style=\"fill: #999;color:#999\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" class=\"list-377408\" width=\"20px\" height=\"20px\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M6 6H4v2h2V6zm14 0H8v2h12V6zM4 11h2v2H4v-2zm16 0H8v2h12v-2zM4 16h2v2H4v-2zm16 0H8v2h12v-2z\" fill=\"currentColor\"><\/path><\/svg><svg style=\"fill: #999;color:#999\" class=\"arrow-unsorted-368013\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"10px\" height=\"10px\" viewBox=\"0 0 24 24\" version=\"1.2\" baseProfile=\"tiny\"><path d=\"M18.2 9.3l-6.2-6.3-6.2 6.3c-.2.2-.3.4-.3.7s.1.5.3.7c.2.2.4.3.7.3h11c.3 0 .5-.1.7-.3.2-.2.3-.5.3-.7s-.1-.5-.3-.7zM5.8 14.7l6.2 6.3 6.2-6.3c.2-.2.3-.5.3-.7s-.1-.5-.3-.7c-.2-.2-.4-.3-.7-.3h-11c-.3 0-.5.1-.7.3-.2.2-.3.5-.3.7s.1.5.3.7z\"\/><\/svg><\/span><\/span><\/span><\/a><\/span><\/div>\n<nav><ul class='ez-toc-list ez-toc-list-level-1 ' ><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-1\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Building_Offline-First_Field_Collection_Web_Apps_with_Progressive_Web_App_PWA_Architecture\" >Building Offline-First Field Collection Web Apps with Progressive Web App (PWA) Architecture<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-2\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Introduction\" >Introduction<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-3\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#1_Understand_the_PWA_Architecture\" >1. Understand the PWA Architecture<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-4\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#11_Service_Worker\" >1.1 Service Worker<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-5\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#12_IndexedDB_and_LocalForage\" >1.2 IndexedDB and LocalForage<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-6\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#13_Web_App_Manifest\" >1.3 Web App Manifest<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-7\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#14_Synchronization_queue\" >1.4 Synchronization queue<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-8\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#2_Set_Up_the_Development_Environment\" >2. Set Up the Development Environment<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-9\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#3_Configure_the_PWA_Build\" >3. Configure the PWA Build<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-10\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Why_use_injectManifest\" >Why use injectManifest?<\/a><\/li><\/ul><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-11\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#4_Create_the_HTML_Interface\" >4. Create the HTML Interface<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-12\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#5_Build_the_Leaflet_Map_Interface\" >5. Build the Leaflet Map Interface<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-13\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#51_Prepare_a_local_vector_layer\" >5.1 Prepare a local vector layer<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-14\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#6_Store_Field_Observations_with_LocalForage\" >6. Store Field Observations with LocalForage<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-15\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#61_Create_the_local_database_and_synchronization_logic\" >6.1 Create the local database and synchronization logic<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-16\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Why_use_one_record_per_key\" >Why use one record per key?<\/a><\/li><\/ul><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-17\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#7_Initialize_Leaflet_and_Load_GPS_Coordinates\" >7. Initialize Leaflet and Load GPS Coordinates<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-18\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#71_Imports_and_map_initialization\" >7.1 Imports and map initialization<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-19\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#72_Set_the_selected_location\" >7.2 Set the selected location<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-20\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#73_Track_GPS_using_navigatorgeolocation\" >7.3 Track GPS using navigator.geolocation<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-21\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#8_Load_the_Custom_Vector_Layer_Offline\" >8. Load the Custom Vector Layer Offline<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-22\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#9_Save_the_Form_as_a_GeoJSON_Observation\" >9. Save the Form as a GeoJSON Observation<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-23\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Why_persist_before_syncing\" >Why persist before syncing?<\/a><\/li><\/ul><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-24\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#10_Build_the_Service_Worker\" >10. Build the Service Worker<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-25\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#101_Understand_the_caching_logic\" >10.1 Understand the caching logic<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-26\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#11_Prepare_an_Offline_Map_Tile_Package\" >11. Prepare an Offline Map Tile Package<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-27\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Important_tile_licensing_limitation\" >Important tile licensing limitation<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-28\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#111_Implement_tile_package_download\" >11.1 Implement tile package download<\/a><\/li><\/ul><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-29\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#12_Refresh_the_Local_Queue_and_Synchronize_Automatically\" >12. Refresh the Local Queue and Synchronize Automatically<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-30\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#121_Add_the_automatic_synchronization_function\" >12.1 Add the automatic synchronization function<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-31\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#What_triggers_synchronization\" >What triggers synchronization?<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-32\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Background_Sync_limitations\" >Background Sync limitations<\/a><\/li><\/ul><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-33\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#13_Connect_the_App_to_PostGIS\" >13. Connect the App to PostGIS<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-34\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#131_Create_a_PostGIS_table\" >13.1 Create a PostGIS table<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-35\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#132_Implement_an_Express_endpoint\" >13.2 Implement an Express endpoint<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-36\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#133_Why_idempotency_matters\" >13.3 Why idempotency matters<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-37\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#14_Test_the_Offline_Workflow\" >14. Test the Offline Workflow<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-38\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#141_Build_and_serve_the_production_bundle\" >14.1 Build and serve the production bundle<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-39\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#142_Run_the_field_collection_test\" >14.2 Run the field collection test<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-40\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#15_Important_Production_Considerations\" >15. Important Production Considerations<\/a><ul class='ez-toc-list-level-2' ><li class='ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-41\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#151_Browser_storage_is_not_a_server_backup\" >15.1 Browser storage is not a server backup<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-42\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#152_IndexedDB_storage_is_not_automatically_a_spatial_database\" >15.2 IndexedDB storage is not automatically a spatial database<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-43\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#153_Do_not_rely_on_cached_external_tiles_for_complete_offline_coverage\" >15.3 Do not rely on cached external tiles for complete offline coverage<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-44\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#154_Treat_sync_as_a_durable_queue\" >15.4 Treat sync as a durable queue<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-45\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#155_Handle_map_GPS_and_data_updates_separately\" >15.5 Handle map, GPS, and data updates separately<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-1'><a class=\"ez-toc-link ez-toc-heading-46\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/#Summary\" >Summary<\/a><\/li><\/ul><\/nav><\/div>\n<h1><span class=\"ez-toc-section\" id=\"Building_Offline-First_Field_Collection_Web_Apps_with_Progressive_Web_App_PWA_Architecture\"><\/span>Building Offline-First Field Collection Web Apps with Progressive Web App (PWA) Architecture<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<h2><span class=\"ez-toc-section\" id=\"Introduction\"><\/span>Introduction<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1521\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_8b6ztv8b6ztv8b6z-1024x572.jpg\" alt=\"Offline First Field Collection Web App PWA GIS interface displayed on a rugged tablet during field data collection\" width=\"1024\" height=\"572\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_8b6ztv8b6ztv8b6z-1024x572.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_8b6ztv8b6ztv8b6z-300x167.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_8b6ztv8b6ztv8b6z-768x429.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_8b6ztv8b6ztv8b6z.jpg 1376w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p>&#8220;Implementing an <b data-path-to-node=\"2,0,1,0,1,0\" data-index-in-node=\"17\">Offline First Field Collection Web App PWA GIS<\/b> 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.<\/p>\n<p>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.<\/p>\n<p>A Progressive Web App (PWA) addresses this problem by combining browser-based mapping with persistent client-side storage, cached application resources, and deferred synchronization.<\/p>\n<p>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.<\/p>\n<p>Three browser technologies form the foundation:<\/p>\n<ul>\n<li>\n<div>\n<div>\n<p>Service Workers: Intercept network requests and provide cached application resources when connectivity is unavailable.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>IndexedDB: Persist structured records, including GeoJSON geometries and survey attributes, in the browser.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Web App Manifest: Defines installation-related metadata such as the app name, icons, display mode, and startup URL.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ul>\n<p>Service workers provide the network interception and caching mechanisms that make offline application shells possible, while IndexedDB supports asynchronous storage of structured data.<\/p>\n<p>In this tutorial, we will develop a field collection application using<a href=\"https:\/\/leafletjs.com\/\" target=\"_blank\" rel=\"noopener\"> Leaflet,<\/a> <a href=\"https:\/\/localforage.github.io\/localForage\/\" target=\"_blank\" rel=\"noopener\">LocalForage,<\/a> a custom service worker, and an API that synchronizes observations with a PostgreSQL\/<a href=\"https:\/\/helpwithmygisassignment.com\/blog\/building-real-time-spatial-dashboards-in-qgis-with-geonode-and-postgis\/\">PostGIS backend<\/a>.<\/p>\n<p>By the end, the application will support:<\/p>\n<ul>\n<li>\n<div>\n<div>\n<p>A Leaflet map with browser GPS tracking.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Offline access to the application interface and a custom vector layer.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Local storage of survey observations and their geometries.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>An optional downloadable offline map tile package.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Automatic synchronization when connectivity returns.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>A server-side API for safely persisting records in PostGIS.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ul>\n<p>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&#8217;s tiles available offline.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"1_Understand_the_PWA_Architecture\"><\/span>1. Understand the PWA Architecture<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>A reliable offline-first GIS should separate data capture, local persistence, map resources, and server synchronization.<\/p>\n<div>\n<div>\n<p>Fieldworker&#8217;s browser<\/p>\n<p>Leaflet map \u00b7 GPS \u00b7 Attribute forms<\/p>\n<\/div>\n<div>\n<p>Store first, synchronize later<\/p>\n<\/div>\n<div>\n<div>\n<div>\n<p>IndexedDB \/ LocalForage<\/p>\n<p>Field observations, geometry, status, and retry queue.<\/p>\n<\/div>\n<\/div>\n<div>\n<div>\n<p>Cache Storage<\/p>\n<p>Application assets, approved map tiles, and cached resources.<\/p>\n<\/div>\n<\/div>\n<\/div>\n<div>\n<p>When connectivity becomes available<\/p>\n<\/div>\n<div>\n<p>Server API \u2192 PostgreSQL \/ PostGIS<\/p>\n<p>Validation \u00b7 Authentication \u00b7 Duplicate-safe writes<\/p>\n<\/div>\n<\/div>\n<h2><span class=\"ez-toc-section\" id=\"11_Service_Worker\"><\/span>1.1 Service Worker<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>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 <code>localhost<\/code> treated as secure for development.<\/p>\n<div>\n<div><\/div>\n<\/div>\n<p>Use it to manage application assets and approved offline map resources\u2014not to store the survey database itself.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"12_IndexedDB_and_LocalForage\"><\/span>1.2 IndexedDB and LocalForage<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>IndexedDB is the browser&#8217;s asynchronous database for structured client-side data. LocalForage provides a simpler key-value interface and normally uses IndexedDB when it is available.<\/p>\n<div>\n<div><\/div>\n<\/div>\n<p>Store each field observation as an individual record. This makes retrying and updating synchronization status easier than repeatedly rewriting a single large array.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"13_Web_App_Manifest\"><\/span>1.3 Web App Manifest<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>The manifest describes how the browser should present the installed application. It does not itself provide offline caching or persistence.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"14_Synchronization_queue\"><\/span>1.4 Synchronization queue<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Every new observation is initially stored locally with a status such as <code>pending<\/code>.<\/p>\n<p>When connectivity returns, the application submits pending records to the API. After the server accepts a record, the local copy is marked <code>synced<\/code>.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"2_Set_Up_the_Development_Environment\"><\/span>2. Set Up the Development Environment<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>We will use Vite to bundle the application and <code>vite-plugin-pwa<\/code> to build the custom service worker with a generated precache manifest.<\/p>\n<p>Install a current Node.js release with npm, then run:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code># Create a vanilla JavaScript project\r\nnpm create vite@latest field-collector -- --template vanilla\r\n\r\n# Enter the project\r\ncd field-collector\r\n\r\n# Install the starter dependencies\r\nnpm install\r\n\r\n# Install GIS and local storage libraries\r\nnpm install leaflet localforage\r\n\r\n# Install the PWA build plugin\r\nnpm install -D vite-plugin-pwa<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Leaflet provides the interactive map, LocalForage handles browser persistence, and the PWA plugin bundles the custom service worker for production.<\/p>\n<p>Create the following structure:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>field-collector\/\r\n\u251c\u2500\u2500 index.html\r\n\u251c\u2500\u2500 vite.config.js\r\n\u251c\u2500\u2500 public\/\r\n\u2502   \u251c\u2500\u2500 data\/\r\n\u2502   \u2502   \u2514\u2500\u2500 field-zones.geojson\r\n\u2502   \u2514\u2500\u2500 icons\/\r\n\u2502       \u251c\u2500\u2500 icon-192.png\r\n\u2502       \u2514\u2500\u2500 icon-512.png\r\n\u2514\u2500\u2500 src\/\r\n    \u251c\u2500\u2500 main.js\r\n    \u251c\u2500\u2500 outbox.js\r\n    \u251c\u2500\u2500 style.css\r\n    \u2514\u2500\u2500 sw.js<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The <code>public<\/code> directory holds static resources that Vite copies into the production build. Files under <code>src<\/code> contain the application, local data-access layer, and custom service worker.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"3_Configure_the_PWA_Build\"><\/span>3. Configure the PWA Build<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>Create <code>vite.config.js<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>import { defineConfig } from \"vite\";\r\nimport { VitePWA } from \"vite-plugin-pwa\";\r\n\r\nexport default defineConfig({\r\n  plugins: [\r\n    VitePWA({\r\n      \/\/ Use our own service worker.\r\n      strategies: \"injectManifest\",\r\n\r\n      \/\/ Location and output name of the custom service worker.\r\n      srcDir: \"src\",\r\n      filename: \"sw.js\",\r\n\r\n      \/\/ Precache the application shell and small static resources.\r\n      injectManifest: {\r\n        globPatterns: [\r\n          \"**\/*.{html,js,css,svg,png,ico,webmanifest,json}\"\r\n        ],\r\n\r\n        \/\/ Large map packages are downloaded selectively instead.\r\n        globIgnores: [\r\n          \"**\/offline-tiles\/**\"\r\n        ],\r\n\r\n        \/\/ Prevent unexpectedly large resources entering the app shell.\r\n        maximumFileSizeToCacheInBytes: 2 * 1024 * 1024\r\n      },\r\n\r\n      \/\/ Define the installation experience.\r\n      manifest: {\r\n        name: \"Offline Field Collector\",\r\n        short_name: \"Field Collector\",\r\n        description: \"Offline-first geographic field data collection.\",\r\n        start_url: \"\/\",\r\n        scope: \"\/\",\r\n        display: \"standalone\",\r\n        background_color: \"#f8fafc\",\r\n        theme_color: \"#174b3a\",\r\n        icons: [\r\n          {\r\n            src: \"\/icons\/icon-192.png\",\r\n            sizes: \"192x192\",\r\n            type: \"image\/png\"\r\n          },\r\n          {\r\n            src: \"\/icons\/icon-512.png\",\r\n            sizes: \"512x512\",\r\n            type: \"image\/png\"\r\n          }\r\n        ]\r\n      },\r\n\r\n      \/\/ Test the production-built worker rather than relying on\r\n      \/\/ development-server behavior for offline verification.\r\n      devOptions: {\r\n        enabled: false\r\n      }\r\n    })\r\n  ]\r\n});<\/code><\/pre>\n<\/div>\n<\/div>\n<h3><span class=\"ez-toc-section\" id=\"Why_use_injectManifest\"><\/span>Why use <code>injectManifest<\/code>?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>A hand-written service worker that caches only <code>index.html<\/code> might fail when the JavaScript and CSS files are not cached. Production build tools often generate filenames that change between releases.<\/p>\n<p>The <code>injectManifest<\/code> approach lets the build tool generate the asset list while retaining custom code for tile packages and background synchronization.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"4_Create_the_HTML_Interface\"><\/span>4. Create the HTML Interface<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>Replace <code>index.html<\/code> with:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>&lt;!doctype html&gt;\r\n&lt;html lang=\"en\"&gt;\r\n  &lt;head&gt;\r\n    &lt;meta charset=\"UTF-8\" \/&gt;\r\n\r\n    &lt;meta\r\n      name=\"viewport\"\r\n      content=\"width=device-width, initial-scale=1.0\"\r\n    \/&gt;\r\n\r\n    &lt;meta name=\"theme-color\" content=\"#174b3a\" \/&gt;\r\n\r\n    &lt;link rel=\"manifest\" href=\"\/manifest.webmanifest\" \/&gt;\r\n\r\n    &lt;title&gt;Offline Field Collector&lt;\/title&gt;\r\n  &lt;\/head&gt;\r\n\r\n  &lt;body&gt;\r\n    &lt;header class=\"app-header\"&gt;\r\n      &lt;div&gt;\r\n        &lt;h1&gt;Field Collector&lt;\/h1&gt;\r\n        &lt;p&gt;Capture observations offline&lt;\/p&gt;\r\n      &lt;\/div&gt;\r\n\r\n      &lt;span id=\"app-status\" role=\"status\" aria-live=\"polite\"&gt;\r\n        Initializing\u2026\r\n      &lt;\/span&gt;\r\n    &lt;\/header&gt;\r\n\r\n    &lt;section class=\"toolbar\"&gt;\r\n      &lt;button id=\"gps-start\" type=\"button\"&gt;\r\n        Start GPS\r\n      &lt;\/button&gt;\r\n\r\n      &lt;button id=\"gps-stop\" type=\"button\" disabled&gt;\r\n        Stop GPS\r\n      &lt;\/button&gt;\r\n\r\n      &lt;button id=\"prepare-tiles\" type=\"button\"&gt;\r\n        Prepare offline map\r\n      &lt;\/button&gt;\r\n\r\n      &lt;button id=\"sync-now\" type=\"button\"&gt;\r\n        Sync now\r\n      &lt;\/button&gt;\r\n    &lt;\/section&gt;\r\n\r\n    &lt;section class=\"survey-panel\"&gt;\r\n      &lt;form id=\"entry-form\"&gt;\r\n        &lt;label&gt;\r\n          Observation category\r\n\r\n          &lt;select name=\"category\" required&gt;\r\n            &lt;option value=\"vegetation\"&gt;Vegetation&lt;\/option&gt;\r\n            &lt;option value=\"water\"&gt;Water&lt;\/option&gt;\r\n            &lt;option value=\"infrastructure\"&gt;Infrastructure&lt;\/option&gt;\r\n            &lt;option value=\"other\"&gt;Other&lt;\/option&gt;\r\n          &lt;\/select&gt;\r\n        &lt;\/label&gt;\r\n\r\n        &lt;label&gt;\r\n          Field notes\r\n\r\n          &lt;textarea\r\n            name=\"notes\"\r\n            rows=\"2\"\r\n            maxlength=\"5000\"\r\n            placeholder=\"Describe the observation\"\r\n          &gt;&lt;\/textarea&gt;\r\n        &lt;\/label&gt;\r\n\r\n        &lt;button type=\"submit\"&gt;\r\n          Save observation\r\n        &lt;\/button&gt;\r\n      &lt;\/form&gt;\r\n\r\n      &lt;p id=\"position-status\"&gt;\r\n        Select a location on the map or obtain a GPS fix.\r\n      &lt;\/p&gt;\r\n\r\n      &lt;p id=\"queue-status\"&gt;No records loaded.&lt;\/p&gt;\r\n\r\n      &lt;ul id=\"record-list\" aria-label=\"Saved observations\"&gt;&lt;\/ul&gt;\r\n    &lt;\/section&gt;\r\n\r\n    &lt;main id=\"map\" aria-label=\"Field collection map\"&gt;&lt;\/main&gt;\r\n\r\n    &lt;script type=\"module\" src=\"\/src\/main.js\"&gt;&lt;\/script&gt;\r\n  &lt;\/body&gt;\r\n&lt;\/html&gt;<\/code><\/pre>\n<\/div>\n<\/div>\n<p>This interface contains the necessary controls for GPS tracking, survey capture, offline tile preparation, and synchronization.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"5_Build_the_Leaflet_Map_Interface\"><\/span>5. Build the Leaflet Map Interface<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>Create <code>src\/style.css<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>* {\r\n  box-sizing: border-box;\r\n}\r\n\r\nhtml,\r\nbody {\r\n  margin: 0;\r\n  height: 100%;\r\n  font-family: system-ui, sans-serif;\r\n  color: #172033;\r\n  background: #f8fafc;\r\n}\r\n\r\nbody {\r\n  min-height: 100vh;\r\n  display: flex;\r\n  flex-direction: column;\r\n}\r\n\r\nbutton,\r\nselect,\r\ntextarea {\r\n  font: inherit;\r\n}\r\n\r\nbutton {\r\n  padding: 9px 12px;\r\n  border: 1px solid #cbd5e1;\r\n  border-radius: 6px;\r\n  background: #ffffff;\r\n  cursor: pointer;\r\n}\r\n\r\nbutton:disabled {\r\n  cursor: not-allowed;\r\n  opacity: 0.5;\r\n}\r\n\r\n.app-header {\r\n  display: flex;\r\n  align-items: center;\r\n  justify-content: space-between;\r\n  gap: 16px;\r\n  padding: 12px 18px;\r\n  background: #174b3a;\r\n  color: #ffffff;\r\n}\r\n\r\n.app-header h1,\r\n.app-header p {\r\n  margin: 0;\r\n}\r\n\r\n.app-header h1 {\r\n  font-size: 1.2rem;\r\n}\r\n\r\n.app-header p {\r\n  margin-top: 4px;\r\n  font-size: 0.85rem;\r\n}\r\n\r\n#app-status {\r\n  font-size: 0.85rem;\r\n}\r\n\r\n.toolbar {\r\n  display: flex;\r\n  flex-wrap: wrap;\r\n  gap: 8px;\r\n  padding: 10px 14px;\r\n  background: #e8efe9;\r\n}\r\n\r\n.survey-panel {\r\n  padding: 12px 14px;\r\n  background: #ffffff;\r\n  border-bottom: 1px solid #dbe2ea;\r\n}\r\n\r\n#entry-form {\r\n  display: flex;\r\n  flex-wrap: wrap;\r\n  gap: 12px;\r\n  align-items: end;\r\n}\r\n\r\n#entry-form label {\r\n  display: grid;\r\n  gap: 5px;\r\n  flex: 1 1 180px;\r\n  font-size: 0.85rem;\r\n}\r\n\r\ntextarea,\r\nselect {\r\n  min-width: 0;\r\n  width: 100%;\r\n  padding: 8px;\r\n  border: 1px solid #cbd5e1;\r\n  border-radius: 5px;\r\n}\r\n\r\n#position-status,\r\n#queue-status {\r\n  margin: 8px 0 0;\r\n  font-size: 0.85rem;\r\n}\r\n\r\n#record-list {\r\n  display: flex;\r\n  flex-wrap: wrap;\r\n  gap: 12px;\r\n  padding-left: 20px;\r\n  margin: 8px 0 0;\r\n  font-size: 0.8rem;\r\n}\r\n\r\n#map {\r\n  flex: 1;\r\n  min-height: 320px;\r\n}\r\n\r\n@media (max-width: 620px) {\r\n  .app-header {\r\n    align-items: flex-start;\r\n    flex-direction: column;\r\n    gap: 8px;\r\n  }\r\n\r\n  #entry-form {\r\n    align-items: stretch;\r\n    flex-direction: column;\r\n  }\r\n}<\/code><\/pre>\n<\/div>\n<\/div>\n<p>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.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"51_Prepare_a_local_vector_layer\"><\/span>5.1 Prepare a local vector layer<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Create <code>public\/data\/field-zones.geojson<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>{\r\n  \"type\": \"FeatureCollection\",\r\n  \"features\": [\r\n    {\r\n      \"type\": \"Feature\",\r\n      \"properties\": {\r\n        \"zone\": \"A\",\r\n        \"name\": \"Sample survey zone\"\r\n      },\r\n      \"geometry\": {\r\n        \"type\": \"Polygon\",\r\n        \"coordinates\": [\r\n          [\r\n            [36.810, -1.280],\r\n            [36.825, -1.280],\r\n            [36.825, -1.295],\r\n            [36.810, -1.295],\r\n            [36.810, -1.280]\r\n          ]\r\n        ]\r\n      }\r\n    }\r\n  ]\r\n}<\/code><\/pre>\n<\/div>\n<\/div>\n<p>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.<\/p>\n<p>Because this is a same-origin local data asset, the application can precache it along with its other small resources.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"6_Store_Field_Observations_with_LocalForage\"><\/span>6. Store Field Observations with LocalForage<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>The application should persist an observation before trying to contact the server.<\/p>\n<p>That ordering is the core of an offline-first system:<\/p>\n<ol>\n<li>\n<div>\n<div>\n<p>Validate the form.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Build the GeoJSON feature and its metadata.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Save it in IndexedDB.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Refresh the local queue.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Attempt synchronization when the network is available.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ol>\n<h2><span class=\"ez-toc-section\" id=\"61_Create_the_local_database_and_synchronization_logic\"><\/span>6.1 Create the local database and synchronization logic<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Create <code>src\/outbox.js<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>import localforage from \"localforage\";\r\n\r\n\/\/ A named IndexedDB-backed store shared by the app and service worker.\r\nexport const outbox = localforage.createInstance({\r\n  name: \"FieldCollectorPWA\",\r\n  storeName: \"field_entries\",\r\n  description: \"Locally captured field observations\"\r\n});\r\n\r\nconst API_ENDPOINT = \"\/api\/observations\";\r\n\r\n\/\/ Return all locally stored field records.\r\nexport async function listFieldEntries() {\r\n  const records = [];\r\n\r\n  await outbox.iterate((value, key) =&gt; {\r\n    if (String(key).startsWith(\"entry:\")) {\r\n      records.push(value);\r\n    }\r\n  });\r\n\r\n  \/\/ Newest observations appear first.\r\n  return records.sort((a, b) =&gt; {\r\n    return b.properties.captured_at.localeCompare(\r\n      a.properties.captured_at\r\n    );\r\n  });\r\n}\r\n\r\n\/\/ Submit pending entries to the server.\r\n\/\/ The server must enforce uniqueness for the record ID.\r\nexport async function syncPendingEntries() {\r\n  const entries = [];\r\n\r\n  await outbox.iterate((value, key) =&gt; {\r\n    if (\r\n      String(key).startsWith(\"entry:\") &amp;&amp;\r\n      value.syncStatus !== \"synced\" &amp;&amp;\r\n      value.syncStatus !== \"needs_review\"\r\n    ) {\r\n      entries.push({ key, value });\r\n    }\r\n  });\r\n\r\n  entries.sort((a, b) =&gt; {\r\n    return a.value.properties.captured_at.localeCompare(\r\n      b.value.properties.captured_at\r\n    );\r\n  });\r\n\r\n  let sent = 0;\r\n\r\n  for (const { key, value } of entries) {\r\n    try {\r\n      const response = await fetch(API_ENDPOINT, {\r\n        method: \"POST\",\r\n        headers: {\r\n          \"Content-Type\": \"application\/json\",\r\n          \"Idempotency-Key\": value.id\r\n        },\r\n        credentials: \"same-origin\",\r\n        body: JSON.stringify(value)\r\n      });\r\n\r\n      \/\/ Most 4xx responses require user or data correction.\r\n      \/\/ HTTP 408 and 429 are treated as retryable.\r\n      if (\r\n        response.status &gt;= 400 &amp;&amp;\r\n        response.status &lt; 500 &amp;&amp;\r\n        ![408, 429].includes(response.status)\r\n      ) {\r\n        const current = await outbox.getItem(key);\r\n\r\n        if (current &amp;&amp; current.syncStatus !== \"synced\") {\r\n          await outbox.setItem(key, {\r\n            ...current,\r\n            syncStatus: \"needs_review\",\r\n            lastSyncError: `HTTP ${response.status}`,\r\n            lastAttemptAt: new Date().toISOString()\r\n          });\r\n        }\r\n\r\n        continue;\r\n      }\r\n\r\n      \/\/ Stop the current batch on a transient server failure.\r\n      if (!response.ok) {\r\n        throw new Error(`Server returned HTTP ${response.status}`);\r\n      }\r\n\r\n      \/\/ Re-read the current record to avoid overwriting a successful\r\n      \/\/ update made by another tab or a concurrent service worker.\r\n      const current = await outbox.getItem(key);\r\n\r\n      if (current &amp;&amp; current.syncStatus !== \"synced\") {\r\n        await outbox.setItem(key, {\r\n          ...current,\r\n          syncStatus: \"synced\",\r\n          syncedAt: new Date().toISOString(),\r\n          lastSyncError: null\r\n        });\r\n      }\r\n\r\n      sent += 1;\r\n    } catch (error) {\r\n      const current = await outbox.getItem(key);\r\n\r\n      if (current &amp;&amp; current.syncStatus !== \"synced\") {\r\n        await outbox.setItem(key, {\r\n          ...current,\r\n          syncStatus: \"pending\",\r\n          syncAttempts: (current.syncAttempts ?? 0) + 1,\r\n          lastAttemptAt: new Date().toISOString(),\r\n          lastSyncError: String(error.message ?? error)\r\n        });\r\n      }\r\n\r\n      \/\/ Stop so the remaining records can be retried later.\r\n      \/\/ In a service-worker sync event, the rejection also allows\r\n      \/\/ the browser to retry the background synchronization task.\r\n      throw new Error(\r\n        `Synchronization stopped after ${sent} successful records: ${error.message}`\r\n      );\r\n    }\r\n  }\r\n\r\n  return { sent, processed: entries.length };\r\n}<\/code><\/pre>\n<\/div>\n<\/div>\n<h3><span class=\"ez-toc-section\" id=\"Why_use_one_record_per_key\"><\/span>Why use one record per key?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Each entry is stored using a key such as <code>entry:550e8400-e29b-41d4-a716-446655440000<\/code>.<\/p>\n<p>That lets the app update a record&#8217;s synchronization state without replacing the entire collection. LocalForage supports promise-based <code>getItem()<\/code>, <code>setItem()<\/code>, and iteration operations.<\/p>\n<div>\n<div><\/div>\n<\/div>\n<p>The <code>needs_review<\/code> status distinguishes records that require correction or authentication from records that are safe to retry automatically.<\/p>\n<p>The <code>Idempotency-Key<\/code> header is only a request hint. The backend must actually enforce duplicate protection, typically through a unique primary key and an idempotent database operation.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"7_Initialize_Leaflet_and_Load_GPS_Coordinates\"><\/span>7. Initialize Leaflet and Load GPS Coordinates<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>Now create <code>src\/main.js<\/code>.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"71_Imports_and_map_initialization\"><\/span>7.1 Imports and map initialization<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<div>\n<div>\n<pre dir=\"ltr\"><code>import L from \"leaflet\";\r\nimport \"leaflet\/dist\/leaflet.css\";\r\nimport \".\/style.css\";\r\n\r\nimport {\r\n  outbox,\r\n  listFieldEntries,\r\n  syncPendingEntries\r\n} from \".\/outbox.js\";\r\n\r\n\/\/ UI elements\r\nconst statusElement = document.getElementById(\"app-status\");\r\nconst positionStatus = document.getElementById(\"position-status\");\r\nconst queueStatus = document.getElementById(\"queue-status\");\r\nconst recordList = document.getElementById(\"record-list\");\r\nconst entryForm = document.getElementById(\"entry-form\");\r\n\r\nconst gpsStartButton = document.getElementById(\"gps-start\");\r\nconst gpsStopButton = document.getElementById(\"gps-stop\");\r\nconst prepareTilesButton = document.getElementById(\"prepare-tiles\");\r\nconst syncNowButton = document.getElementById(\"sync-now\");\r\n\r\n\/\/ This setting must be changed to true when a licensed,\r\n\/\/ same-origin offline tile package is available.\r\nconst USE_LOCAL_TILE_PACKAGE = false;\r\n\r\n\/\/ Initialize a Leaflet map centered on the sample survey area.\r\nconst map = L.map(\"map\").setView([-1.2864, 36.8172], 13);\r\n\r\n\/\/ Online-only demonstration basemap.\r\n\/\/ Its tiles are deliberately not precached by this application.\r\nif (USE_LOCAL_TILE_PACKAGE) {\r\n  L.tileLayer(\"\/offline-tiles\/{z}\/{x}\/{y}.png\", {\r\n    minZoom: 8,\r\n    maxZoom: 18,\r\n    attribution: \"Attribution for your licensed tile source\"\r\n  }).addTo(map);\r\n} else {\r\n  L.tileLayer(\"https:\/\/tile.openstreetmap.org\/{z}\/{x}\/{y}.png\", {\r\n    maxZoom: 19,\r\n    attribution: \"&amp;copy; OpenStreetMap contributors\"\r\n  }).addTo(map);\r\n}\r\n\r\n\/\/ Keep GPS selection separate from map display and database storage.\r\nlet selectedLocation = null;\r\nlet locationMarker = null;\r\nlet gpsWatchId = null;\r\nlet hasCenteredOnGps = false;\r\n\r\nfunction setStatus(message) {\r\n  statusElement.textContent = message;\r\n}<\/code><\/pre>\n<\/div>\n<\/div>\n<p>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.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"72_Set_the_selected_location\"><\/span>7.2 Set the selected location<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Add:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>function setSelectedLocation(latlng, accuracyM = null, source = \"map\") {\r\n  selectedLocation = {\r\n    lat: latlng.lat,\r\n    lng: latlng.lng,\r\n    accuracyM,\r\n    source,\r\n    capturedAt: new Date().toISOString()\r\n  };\r\n\r\n  \/\/ Create the marker once and update it on subsequent fixes.\r\n  if (!locationMarker) {\r\n    locationMarker = L.circleMarker(latlng, {\r\n      radius: 8,\r\n      color: \"#ffffff\",\r\n      weight: 2,\r\n      fillColor: \"#0d8f64\",\r\n      fillOpacity: 1\r\n    }).addTo(map);\r\n  } else {\r\n    locationMarker.setLatLng(latlng);\r\n  }\r\n\r\n  const accuracyText = Number.isFinite(accuracyM)\r\n    ? ` \u00b7 estimated accuracy ${Math.round(accuracyM)} m`\r\n    : \"\";\r\n\r\n  positionStatus.textContent =\r\n    `${source === \"gps\" ? \"GPS position\" : \"Selected position\"}: ` +\r\n    `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}` +\r\n    accuracyText;\r\n}\r\n\r\n\/\/ Allow manual point selection as a GPS fallback.\r\nmap.on(\"click\", (event) =&gt; {\r\n  setSelectedLocation(event.latlng, null, \"map\");\r\n});<\/code><\/pre>\n<\/div>\n<\/div>\n<p>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.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"73_Track_GPS_using_navigatorgeolocation\"><\/span>7.3 Track GPS using <code>navigator.geolocation<\/code><span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Add:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>function startGpsTracking() {\r\n  \/\/ Geolocation normally requires HTTPS or localhost and user permission.\r\n  if (!(\"geolocation\" in navigator)) {\r\n    setStatus(\"This browser does not support geolocation.\");\r\n    return;\r\n  }\r\n\r\n  if (gpsWatchId !== null) {\r\n    return;\r\n  }\r\n\r\n  gpsWatchId = navigator.geolocation.watchPosition(\r\n    (position) =&gt; {\r\n      const {\r\n        latitude,\r\n        longitude,\r\n        accuracy\r\n      } = position.coords;\r\n\r\n      const latlng = L.latLng(latitude, longitude);\r\n\r\n      \/\/ Update the currently selected field location.\r\n      setSelectedLocation(latlng, accuracy, \"gps\");\r\n\r\n      \/\/ Center on the first fix without forcing the map to jump\r\n      \/\/ on every later GPS update.\r\n      if (!hasCenteredOnGps) {\r\n        map.setView(latlng, 17);\r\n        hasCenteredOnGps = true;\r\n      }\r\n\r\n      setStatus(\"GPS position updated.\");\r\n    },\r\n\r\n    (error) =&gt; {\r\n      const messages = {\r\n        1: \"Location permission was denied.\",\r\n        2: \"The position is unavailable.\",\r\n        3: \"The GPS request timed out.\"\r\n      };\r\n\r\n      setStatus(messages[error.code] ?? error.message);\r\n    },\r\n\r\n    {\r\n      enableHighAccuracy: true,\r\n      timeout: 15000,\r\n      maximumAge: 5000\r\n    }\r\n  );\r\n\r\n  gpsStartButton.disabled = true;\r\n  gpsStopButton.disabled = false;\r\n\r\n  setStatus(\"Waiting for GPS position\u2026\");\r\n}\r\n\r\nfunction stopGpsTracking() {\r\n  if (gpsWatchId !== null) {\r\n    navigator.geolocation.clearWatch(gpsWatchId);\r\n    gpsWatchId = null;\r\n  }\r\n\r\n  gpsStartButton.disabled = false;\r\n  gpsStopButton.disabled = true;\r\n\r\n  setStatus(\"GPS tracking stopped.\");\r\n}\r\n\r\ngpsStartButton.addEventListener(\"click\", startGpsTracking);\r\ngpsStopButton.addEventListener(\"click\", stopGpsTracking);<\/code><\/pre>\n<\/div>\n<\/div>\n<p><code>navigator.geolocation.watchPosition()<\/code> requests successive position updates. Leaflet&#8217;s own <code>map.locate({ watch: true })<\/code> is another option, but using the browser API directly makes the GPS acquisition logic explicit.<\/p>\n<p>Field accuracy matters: the reported <code>accuracy<\/code> value estimates horizontal uncertainty in meters. Save it with the observation and define a project-specific rule for accepting or rejecting low-quality fixes.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"8_Load_the_Custom_Vector_Layer_Offline\"><\/span>8. Load the Custom Vector Layer Offline<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>The survey zones are static GeoJSON resources, so we can load them from the same origin as the application.<\/p>\n<p>Add the following code to <code>main.js<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>async function loadFieldZones() {\r\n  const response = await fetch(\"\/data\/field-zones.geojson\");\r\n\r\n  if (!response.ok) {\r\n    throw new Error(\r\n      `Unable to load field zones: HTTP ${response.status}`\r\n    );\r\n  }\r\n\r\n  const geojson = await response.json();\r\n\r\n  \/\/ Convert GeoJSON features into Leaflet layers.\r\n  const zoneLayer = L.geoJSON(geojson, {\r\n    style: {\r\n      color: \"#147d53\",\r\n      weight: 2,\r\n      fillColor: \"#38a169\",\r\n      fillOpacity: 0.2\r\n    },\r\n\r\n    onEachFeature: (feature, layer) =&gt; {\r\n      const zoneName = feature.properties?.name ?? \"Unnamed zone\";\r\n\r\n      layer.bindTooltip(String(zoneName));\r\n    }\r\n  });\r\n\r\n  zoneLayer.addTo(map);\r\n}\r\n\r\n\/\/ Report failures without taking down data capture.\r\nloadFieldZones().catch((error) =&gt; {\r\n  console.error(error);\r\n  setStatus(\"Survey zones unavailable; local capture remains usable.\");\r\n});<\/code><\/pre>\n<\/div>\n<\/div>\n<p>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.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"9_Save_the_Form_as_a_GeoJSON_Observation\"><\/span>9. Save the Form as a GeoJSON Observation<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>A field observation should capture both its geometry and its properties.<\/p>\n<p>For this implementation, the stored record is a GeoJSON <code>Feature<\/code> with a <code>Point<\/code> geometry. Line and polygon capture can be added later using Leaflet drawing tools; those geometries can be persisted in the same general record structure.<\/p>\n<p>Add the following to <code>main.js<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>function createObservation(formData) {\r\n  if (!selectedLocation) {\r\n    throw new Error(\r\n      \"Select a map location or obtain a GPS fix before saving.\"\r\n    );\r\n  }\r\n\r\n  const now = new Date().toISOString();\r\n\r\n  return {\r\n    type: \"Feature\",\r\n\r\n    \/\/ Stable local ID, also used as the server idempotency key.\r\n    id: crypto.randomUUID(),\r\n\r\n    \/\/ GeoJSON always stores coordinate arrays as [longitude, latitude].\r\n    geometry: {\r\n      type: \"Point\",\r\n      coordinates: [\r\n        selectedLocation.lng,\r\n        selectedLocation.lat\r\n      ]\r\n    },\r\n\r\n    properties: {\r\n      category: String(formData.get(\"category\")),\r\n      notes: String(formData.get(\"notes\") ?? \"\").trim(),\r\n\r\n      accuracy_m: selectedLocation.accuracyM,\r\n      location_source: selectedLocation.source,\r\n\r\n      captured_at: selectedLocation.capturedAt ?? now,\r\n      client_created_at: now\r\n    },\r\n\r\n    \/\/ Synchronization metadata stays outside the feature properties.\r\n    syncStatus: \"pending\",\r\n    syncAttempts: 0,\r\n    createdAt: now,\r\n    lastSyncError: null\r\n  };\r\n}\r\n\r\nentryForm.addEventListener(\"submit\", async (event) =&gt; {\r\n  event.preventDefault();\r\n\r\n  try {\r\n    const formData = new FormData(entryForm);\r\n    const observation = createObservation(formData);\r\n\r\n    \/\/ Persist the observation before attempting any API request.\r\n    await outbox.setItem(\r\n      `entry:${observation.id}`,\r\n      observation\r\n    );\r\n\r\n    setStatus(\"Observation saved locally.\");\r\n\r\n    \/\/ Reset the notes while leaving the category selected.\r\n    entryForm.elements.notes.value = \"\";\r\n\r\n    await refreshRecordList();\r\n\r\n    \/\/ Queue a server synchronization attempt when possible.\r\n    await scheduleSync();\r\n\r\n  } catch (error) {\r\n    console.error(\"Could not save observation:\", error);\r\n    setStatus(error.message ?? \"Failed to save observation.\");\r\n  }\r\n});<\/code><\/pre>\n<\/div>\n<\/div>\n<h3><span class=\"ez-toc-section\" id=\"Why_persist_before_syncing\"><\/span>Why persist before syncing?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>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.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"10_Build_the_Service_Worker\"><\/span>10. Build the Service Worker<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>Create <code>src\/sw.js<\/code>.<\/p>\n<p>This worker handles three tasks:<\/p>\n<ol>\n<li>\n<div>\n<div>\n<p>Precache the application shell.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Cache approved same-origin offline tiles.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Process background synchronization requests where the browser supports the Background Synchronization API.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ol>\n<div>\n<div>\n<pre dir=\"ltr\"><code>import {\r\n  precacheAndRoute,\r\n  cleanupOutdatedCaches\r\n} from \"workbox-precaching\";\r\n\r\nimport { syncPendingEntries } from \".\/outbox.js\";\r\n\r\n\/\/ Injected by vite-plugin-pwa during the production build.\r\n\/\/ Includes the application's generated HTML, JS, CSS, and small data assets.\r\nprecacheAndRoute(self.__WB_MANIFEST);\r\n\r\n\/\/ Clean up outdated Workbox precaches after application updates.\r\ncleanupOutdatedCaches();\r\n\r\nconst TILE_CACHE = \"field-offline-tiles-v1\";\r\n\r\n\/\/ Intercept only GET requests for our own offline tile package.\r\n\/\/ Third-party basemaps are intentionally excluded.\r\nself.addEventListener(\"fetch\", (event) =&gt; {\r\n  const request = event.request;\r\n  const url = new URL(request.url);\r\n\r\n  if (\r\n    request.method !== \"GET\" ||\r\n    url.origin !== self.location.origin ||\r\n    !url.pathname.startsWith(\"\/offline-tiles\/\")\r\n  ) {\r\n    return;\r\n  }\r\n\r\n  event.respondWith(\r\n    (async () =&gt; {\r\n      const cache = await caches.open(TILE_CACHE);\r\n\r\n      \/\/ Prefer a prepared or previously cached local tile.\r\n      const cached = await cache.match(request);\r\n\r\n      if (cached) {\r\n        return cached;\r\n      }\r\n\r\n      \/\/ Online fallback: fetch and cache only same-origin package files.\r\n      try {\r\n        const response = await fetch(request);\r\n\r\n        if (response.ok) {\r\n          await cache.put(request, response.clone());\r\n        }\r\n\r\n        return response;\r\n      } catch {\r\n        \/\/ Do not pretend a missing tile was successfully rendered.\r\n        return new Response(\"Tile unavailable offline.\", {\r\n          status: 503,\r\n          headers: {\r\n            \"Content-Type\": \"text\/plain\"\r\n          }\r\n        });\r\n      }\r\n    })()\r\n  );\r\n});\r\n\r\n\/\/ Enhanced synchronization in browsers supporting Background Sync.\r\n\/\/ The shared module accesses the same IndexedDB-backed LocalForage store.\r\nself.addEventListener(\"sync\", (event) =&gt; {\r\n  if (event.tag === \"sync-observations\") {\r\n    event.waitUntil(syncPendingEntries());\r\n  }\r\n});<\/code><\/pre>\n<\/div>\n<\/div>\n<h2><span class=\"ez-toc-section\" id=\"101_Understand_the_caching_logic\"><\/span>10.1 Understand the caching logic<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><code>precacheAndRoute()<\/code> is responsible for the application shell and the static assets listed by the build tool.<\/p>\n<p>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.<\/p>\n<p>This creates two resource lifecycles:<\/p>\n<div>\n<table>\n<tbody>\n<tr>\n<th>Resource<\/th>\n<th>Storage mechanism<\/th>\n<th>Update strategy<\/th>\n<\/tr>\n<tr>\n<td>HTML, bundled JS and CSS<\/td>\n<td>Workbox precache<\/td>\n<td>Build version and deployment<\/td>\n<\/tr>\n<tr>\n<td>Small local GeoJSON<\/td>\n<td>Workbox precache<\/td>\n<td>Explicit application version\/update<\/td>\n<\/tr>\n<tr>\n<td>Approved offline tiles<\/td>\n<td>Dedicated Cache Storage cache<\/td>\n<td>User-selected package download\/update<\/td>\n<\/tr>\n<tr>\n<td>Survey observations<\/td>\n<td>IndexedDB via LocalForage<\/td>\n<td>Record-level synchronization status<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"11_Prepare_an_Offline_Map_Tile_Package\"><\/span>11. Prepare an Offline Map Tile Package<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>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.<\/p>\n<p>The app therefore has a Prepare offline map button. It downloads a curated package of same-origin tile URLs into the dedicated cache.<\/p>\n<p>First, prepare a tile package using a provider or data pipeline that explicitly permits offline storage and use. Host the permitted files under:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>public\/\r\n\u2514\u2500\u2500 offline-tiles\/\r\n    \u251c\u2500\u2500 manifest.json\r\n    \u2514\u2500\u2500 z\/\r\n        \u2514\u2500\u2500 x\/\r\n            \u2514\u2500\u2500 y.png<\/code><\/pre>\n<\/div>\n<\/div>\n<p>For a raster package in standard XYZ layout, paths generally follow <code>{z}\/{x}\/{y}.png<\/code>. Ensure the selected Leaflet URL pattern matches the actual package structure.<\/p>\n<p>Create <code>public\/offline-tiles\/manifest.json<\/code> containing the exact tile paths prepared for your survey area:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>{\r\n  \"tiles\": [\r\n    \"\/offline-tiles\/12\/1205\/1538.png\",\r\n    \"\/offline-tiles\/12\/1205\/1539.png\",\r\n    \"\/offline-tiles\/12\/1206\/1538.png\"\r\n  ]\r\n}<\/code><\/pre>\n<\/div>\n<\/div>\n<p>These paths are illustrative. They must correspond to actual tiles in the package.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Important_tile_licensing_limitation\"><\/span>Important tile licensing limitation<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>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.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"111_Implement_tile_package_download\"><\/span>11.1 Implement tile package download<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Add the following function to <code>main.js<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>async function prepareOfflineTilePackage() {\r\n  prepareTilesButton.disabled = true;\r\n\r\n  try {\r\n    setStatus(\"Preparing offline map tiles\u2026\");\r\n\r\n    \/\/ This manifest describes the package for the current study area.\r\n    const response = await fetch(\"\/offline-tiles\/manifest.json\");\r\n\r\n    if (!response.ok) {\r\n      throw new Error(\r\n        \"Tile manifest unavailable. Prepare a licensed tile package first.\"\r\n      );\r\n    }\r\n\r\n    const manifest = await response.json();\r\n\r\n    if (!Array.isArray(manifest.tiles) || manifest.tiles.length === 0) {\r\n      throw new Error(\"The tile package contains no tile paths.\");\r\n    }\r\n\r\n    \/\/ Cache key must match the service worker's dedicated tile cache.\r\n    const cache = await caches.open(\"field-offline-tiles-v1\");\r\n    const tilePaths = [...new Set(manifest.tiles)];\r\n\r\n    let completed = 0;\r\n\r\n    \/\/ Small batches limit concurrency and make progress visible.\r\n    const batchSize = 30;\r\n\r\n    for (let i = 0; i &lt; tilePaths.length; i += batchSize) {\r\n      const batch = tilePaths.slice(i, i + batchSize);\r\n\r\n      await Promise.all(\r\n        batch.map(async (path) =&gt; {\r\n          const url = new URL(path, window.location.origin);\r\n\r\n          \/\/ Only same-origin offline package resources are accepted.\r\n          if (\r\n            url.origin !== window.location.origin ||\r\n            !url.pathname.startsWith(\"\/offline-tiles\/\")\r\n          ) {\r\n            throw new Error(`Invalid tile package path: ${path}`);\r\n          }\r\n\r\n          \/\/ Skip tiles already downloaded.\r\n          if (!(await cache.match(url.href))) {\r\n            await cache.add(url.href);\r\n          }\r\n\r\n          completed += 1;\r\n        })\r\n      );\r\n\r\n      setStatus(\r\n        `Offline tiles: ${completed} \/ ${tilePaths.length}`\r\n      );\r\n    }\r\n\r\n    setStatus(`Prepared ${completed} offline map tiles.`);\r\n\r\n  } catch (error) {\r\n    console.error(error);\r\n    setStatus(error.message ?? \"Offline tile preparation failed.\");\r\n  } finally {\r\n    prepareTilesButton.disabled = false;\r\n  }\r\n}\r\n\r\nprepareTilesButton.addEventListener(\r\n  \"click\",\r\n  prepareOfflineTilePackage\r\n);<\/code><\/pre>\n<\/div>\n<\/div>\n<p>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.<\/p>\n<p>After the tile package is hosted, change:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>const USE_LOCAL_TILE_PACKAGE = false;<\/code><\/pre>\n<\/div>\n<\/div>\n<p>to:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>const USE_LOCAL_TILE_PACKAGE = true;<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The Leaflet map will then request the local tiles instead of the online OpenStreetMap basemap.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"12_Refresh_the_Local_Queue_and_Synchronize_Automatically\"><\/span>12. Refresh the Local Queue and Synchronize Automatically<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>The next step connects LocalForage to the user interface.<\/p>\n<p>Add these functions to <code>main.js<\/code>:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>async function refreshRecordList() {\r\n  const records = await listFieldEntries();\r\n\r\n  const pending = records.filter(\r\n    (record) =&gt; record.syncStatus === \"pending\"\r\n  );\r\n\r\n  const needsReview = records.filter(\r\n    (record) =&gt; record.syncStatus === \"needs_review\"\r\n  );\r\n\r\n  const synced = records.filter(\r\n    (record) =&gt; record.syncStatus === \"synced\"\r\n  );\r\n\r\n  queueStatus.textContent =\r\n    `${pending.length} pending \u00b7 ` +\r\n    `${synced.length} synced \u00b7 ` +\r\n    `${needsReview.length} need review`;\r\n\r\n  \/\/ Rebuild the visible list without injecting survey notes as HTML.\r\n  recordList.replaceChildren();\r\n\r\n  for (const record of records.slice(0, 15)) {\r\n    const item = document.createElement(\"li\");\r\n    const category = record.properties.category;\r\n    const status = record.syncStatus;\r\n\r\n    item.textContent =\r\n      `${category} \u2014 ${status} \u2014 ${record.id.slice(0, 8)}`;\r\n\r\n    recordList.appendChild(item);\r\n  }\r\n}<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Displaying records with <code>textContent<\/code> avoids treating user-entered notes or attributes as HTML.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"121_Add_the_automatic_synchronization_function\"><\/span>12.1 Add the automatic synchronization function<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<div>\n<div>\n<pre dir=\"ltr\"><code>let foregroundSyncRunning = false;\r\n\r\nasync function scheduleSync(forceForeground = false) {\r\n  const records = await listFieldEntries();\r\n\r\n  const pending = records.filter(\r\n    (record) =&gt;\r\n      record.syncStatus === \"pending\" ||\r\n      !record.syncStatus\r\n  );\r\n\r\n  if (pending.length === 0) {\r\n    setStatus(\"No records awaiting synchronization.\");\r\n    await refreshRecordList();\r\n    return;\r\n  }\r\n\r\n  if (!navigator.onLine) {\r\n    setStatus(\"Offline: observations remain stored locally.\");\r\n    return;\r\n  }\r\n\r\n  \/\/ Avoid overlapping foreground attempts in this page.\r\n  if (foregroundSyncRunning) {\r\n    return;\r\n  }\r\n\r\n  try {\r\n    \/\/ Background Sync is an enhancement, not a universal guarantee.\r\n    if (!forceForeground &amp;&amp; \"serviceWorker\" in navigator) {\r\n      const registration = await navigator.serviceWorker.ready;\r\n\r\n      if (registration.sync?.register) {\r\n        await registration.sync.register(\"sync-observations\");\r\n        setStatus(\"Synchronization queued for the background.\");\r\n        return;\r\n      }\r\n    }\r\n  } catch (error) {\r\n    console.warn(\"Background Sync unavailable:\", error);\r\n  }\r\n\r\n  \/\/ Fallback for browsers without Background Sync or when the user\r\n  \/\/ explicitly requests immediate synchronization.\r\n  foregroundSyncRunning = true;\r\n\r\n  try {\r\n    const report = await syncPendingEntries();\r\n\r\n    setStatus(\r\n      `Synchronization complete: ${report.sent} records submitted.`\r\n    );\r\n  } catch (error) {\r\n    setStatus(\r\n      `Synchronization incomplete. Records remain queued: ${error.message}`\r\n    );\r\n  } finally {\r\n    foregroundSyncRunning = false;\r\n    await refreshRecordList();\r\n  }\r\n}\r\n\r\n\/\/ Manual retry\r\nsyncNowButton.addEventListener(\"click\", () =&gt; {\r\n  void scheduleSync(true);\r\n});\r\n\r\n\/\/ Network changes: use them as hints to retry.\r\nwindow.addEventListener(\"online\", () =&gt; {\r\n  void scheduleSync();\r\n});\r\n\r\nwindow.addEventListener(\"offline\", () =&gt; {\r\n  setStatus(\"Offline: local data capture is still available.\");\r\n});\r\n\r\n\/\/ Retry when the user returns to the application.\r\ndocument.addEventListener(\"visibilitychange\", () =&gt; {\r\n  if (document.visibilityState === \"visible\") {\r\n    void scheduleSync(true);\r\n  }\r\n});\r\n\r\n\/\/ Register the custom PWA worker in production.\r\nif (\"serviceWorker\" in navigator &amp;&amp; import.meta.env.PROD) {\r\n  window.addEventListener(\"load\", async () =&gt; {\r\n    try {\r\n      await navigator.serviceWorker.register(\"\/sw.js\");\r\n      setStatus(\"Application ready.\");\r\n    } catch (error) {\r\n      console.error(\"Service Worker registration failed:\", error);\r\n      setStatus(\"Offline application caching could not be enabled.\");\r\n    }\r\n  });\r\n}\r\n\r\n\/\/ Load the local queue on startup.\r\nrefreshRecordList().catch(console.error);\r\n\r\n\/\/ Attempt recovery when the application starts.\r\nwindow.addEventListener(\"load\", () =&gt; {\r\n  void scheduleSync();\r\n});<\/code><\/pre>\n<\/div>\n<\/div>\n<h3><span class=\"ez-toc-section\" id=\"What_triggers_synchronization\"><\/span>What triggers synchronization?<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>The application tries to synchronize when:<\/p>\n<ul>\n<li>\n<div>\n<div>\n<p>A new observation has been saved.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>The browser reports that it is online.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>The user returns to the application.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>The user presses Sync now.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>The browser runs a registered Background Sync task, where supported.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ul>\n<p>The online event and <code>navigator.onLine<\/code> 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.<\/p>\n<h3><span class=\"ez-toc-section\" id=\"Background_Sync_limitations\"><\/span>Background Sync limitations<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"13_Connect_the_App_to_PostGIS\"><\/span>13. Connect the App to PostGIS<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>So far, the application stores observations in IndexedDB. To make them available to other users and systems, we need a server-side API.<\/p>\n<p>We will use Node.js, Express, <code>pg<\/code>, and PostgreSQL\/PostGIS. You can adapt the API contract for a GeoJSON service instead.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"131_Create_a_PostGIS_table\"><\/span>13.1 Create a PostGIS table<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>First, enable PostGIS in your database:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>CREATE EXTENSION IF NOT EXISTS postgis;<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Create the observations table:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>CREATE TABLE field_observations (\r\n    id UUID PRIMARY KEY,\r\n\r\n    category TEXT NOT NULL,\r\n    notes TEXT,\r\n\r\n    captured_at TIMESTAMPTZ NOT NULL,\r\n    accuracy_m DOUBLE PRECISION,\r\n\r\n    geom geometry(Point, 4326) NOT NULL,\r\n\r\n    created_at TIMESTAMPTZ NOT NULL DEFAULT now()\r\n);\r\n\r\n-- Spatial index for future mapping and spatial queries.\r\nCREATE INDEX field_observations_geom_gix\r\n    ON field_observations\r\n    USING GIST (geom);<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The UUID primary key supports idempotent writes. The <code>geometry(Point, 4326)<\/code> column stores longitude\/latitude point geometries in the WGS 84 reference system.<\/p>\n<p>For projects requiring line or polygon surveys, consider a separate table or a suitable generic geometry column with validation for allowed geometry types.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"132_Implement_an_Express_endpoint\"><\/span>13.2 Implement an Express endpoint<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Install backend dependencies in your server project:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>npm install express pg<\/code><\/pre>\n<\/div>\n<\/div>\n<p>The following is a minimal example of an insertion endpoint:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>import express from \"express\";\r\nimport pg from \"pg\";\r\n\r\nconst app = express();\r\n\r\nconst pool = new pg.Pool({\r\n  connectionString: process.env.DATABASE_URL\r\n});\r\n\r\napp.use(express.json({ limit: \"1mb\" }));\r\n\r\n\/\/ Add real authentication middleware before exposing this endpoint.\r\n\/\/ For example: app.post(\"\/api\/observations\", requireAuth, handler);\r\napp.post(\"\/api\/observations\", async (req, res, next) =&gt; {\r\n  try {\r\n    const feature = req.body;\r\n\r\n    \/\/ Validate the basic GeoJSON and the expected point geometry.\r\n    if (\r\n      feature?.type !== \"Feature\" ||\r\n      feature?.geometry?.type !== \"Point\" ||\r\n      !Array.isArray(feature.geometry.coordinates)\r\n    ) {\r\n      return res.status(422).json({\r\n        error: \"A GeoJSON Point feature is required.\"\r\n      });\r\n    }\r\n\r\n    const [longitude, latitude] = feature.geometry.coordinates;\r\n    const properties = feature.properties ?? {};\r\n\r\n    if (\r\n      !Number.isFinite(longitude) ||\r\n      !Number.isFinite(latitude) ||\r\n      longitude &lt; -180 ||\r\n      longitude &gt; 180 ||\r\n      latitude &lt; -90 ||\r\n      latitude &gt; 90\r\n    ) {\r\n      return res.status(422).json({\r\n        error: \"Invalid longitude or latitude.\"\r\n      });\r\n    }\r\n\r\n    if (\r\n      typeof feature.id !== \"string\" ||\r\n      !\/^[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)\r\n    ) {\r\n      return res.status(422).json({\r\n        error: \"A valid UUID is required.\"\r\n      });\r\n    }\r\n\r\n    const category = String(properties.category ?? \"\").trim();\r\n    const notes = String(properties.notes ?? \"\");\r\n    const capturedAt = properties.captured_at;\r\n    const accuracy = properties.accuracy_m;\r\n\r\n    const parsedDate = new Date(capturedAt);\r\n\r\n    if (!category || Number.isNaN(parsedDate.getTime())) {\r\n      return res.status(422).json({\r\n        error: \"Category and a valid capture timestamp are required.\"\r\n      });\r\n    }\r\n\r\n    \/\/ Check the declared idempotency key against the body ID.\r\n    const idempotencyKey = req.get(\"Idempotency-Key\");\r\n\r\n    if (idempotencyKey &amp;&amp; idempotencyKey !== feature.id) {\r\n      return res.status(422).json({\r\n        error: \"Idempotency-Key does not match the observation ID.\"\r\n      });\r\n    }\r\n\r\n    \/\/ Parameterized SQL prevents the values from being interpreted as SQL.\r\n    await pool.query(\r\n      `\r\n      INSERT INTO field_observations (\r\n        id,\r\n        category,\r\n        notes,\r\n        captured_at,\r\n        accuracy_m,\r\n        geom\r\n      )\r\n      VALUES (\r\n        $1,\r\n        $2,\r\n        $3,\r\n        $4,\r\n        $5,\r\n        ST_SetSRID(ST_MakePoint($6, $7), 4326)\r\n      )\r\n      ON CONFLICT (id) DO NOTHING\r\n      `,\r\n      [\r\n        feature.id,\r\n        category,\r\n        notes,\r\n        parsedDate.toISOString(),\r\n        Number.isFinite(accuracy) ? accuracy : null,\r\n        longitude,\r\n        latitude\r\n      ]\r\n    );\r\n\r\n    \/\/ Repeated submissions with the same UUID do not create a duplicate.\r\n    return res.status(200).json({\r\n      id: feature.id,\r\n      accepted: true\r\n    });\r\n\r\n  } catch (error) {\r\n    next(error);\r\n  }\r\n});\r\n\r\napp.listen(3000, () =&gt; {\r\n  console.log(\"Field API listening on port 3000\");\r\n});<\/code><\/pre>\n<\/div>\n<\/div>\n<p>This API validates essential input, uses parameterized SQL, stores geometries with <code>ST_MakePoint()<\/code>, and enforces uniqueness with the primary key.<\/p>\n<p>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\u2014not a complete access-control system. Deploy it behind HTTPS and use a secure session or token design appropriate to your application.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"133_Why_idempotency_matters\"><\/span>13.3 Why idempotency matters<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Consider this sequence:<\/p>\n<ol>\n<li>\n<div>\n<div>\n<p>The browser sends an observation.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>PostGIS successfully inserts it.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>The response fails to reach the device.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>The browser considers the entry pending and retries.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>The database recognizes the existing primary key and does not insert it again.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ol>\n<p>The application can then mark the local record as synchronized after receiving the successful response.<\/p>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"14_Test_the_Offline_Workflow\"><\/span>14. Test the Offline Workflow<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>Do not test only by refreshing the page while connected. Test the full field lifecycle.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"141_Build_and_serve_the_production_bundle\"><\/span>14.1 Build and serve the production bundle<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Build the application:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>npm run build<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Then use Vite&#8217;s preview server:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>npm run preview<\/code><\/pre>\n<\/div>\n<\/div>\n<p>Open the preview URL in the browser. The custom service worker is configured for the production build.<\/p>\n<p>For local testing, <code>localhost<\/code> qualifies as a secure context for service workers. Production must be served over HTTPS.<\/p>\n<div>\n<div><\/div>\n<\/div>\n<h2><span class=\"ez-toc-section\" id=\"142_Run_the_field_collection_test\"><\/span>14.2 Run the field collection test<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Open the application while connected and wait for the initial resources to cache.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Confirm that the local GeoJSON layer appears.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Grant location permission and obtain a GPS fix, or click the map.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Save a field observation and check its pending status.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Switch the browser to offline mode.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Refresh or reopen the already cached application.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Verify that the observation remains in the local queue.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Restore connectivity and press Sync now.<\/label><\/div>\n<div><input disabled=\"disabled\" type=\"checkbox\" \/><label>Verify the record in PostGIS and confirm the local status changes to synced.<\/label><\/div>\n<div><\/div>\n<p>0 of 9 verification steps completed<\/p>\n<\/div>\n<p>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.<\/p>\n<div><\/div>\n<h1><span class=\"ez-toc-section\" id=\"15_Important_Production_Considerations\"><\/span>15. Important Production Considerations<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<h2><span class=\"ez-toc-section\" id=\"151_Browser_storage_is_not_a_server_backup\"><\/span>15.1 Browser storage is not a server backup<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>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.<\/p>\n<p>For a field deployment, consider requesting persistent storage where available:<\/p>\n<div>\n<div>\n<pre dir=\"ltr\"><code>async function requestPersistentStorage() {\r\n  if (!navigator.storage?.persist) {\r\n    return false;\r\n  }\r\n\r\n  return navigator.storage.persist();\r\n}<\/code><\/pre>\n<\/div>\n<\/div>\n<p>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.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"152_IndexedDB_storage_is_not_automatically_a_spatial_database\"><\/span>15.2 IndexedDB storage is not automatically a spatial database<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>The example stores GeoJSON in IndexedDB; it does not create an R-tree or other spatial index.<\/p>\n<p>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.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"153_Do_not_rely_on_cached_external_tiles_for_complete_offline_coverage\"><\/span>15.3 Do not rely on cached external tiles for complete offline coverage<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>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.<\/p>\n<p>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.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"154_Treat_sync_as_a_durable_queue\"><\/span>15.4 Treat sync as a durable queue<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>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:<\/p>\n<ul>\n<li>\n<div>\n<div>\n<p>Pending: stored locally and awaiting submission.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Synced: accepted by the server.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<li>\n<div>\n<div>\n<p>Needs review: rejected or blocked until a user or administrator resolves the problem.<\/p>\n<\/div>\n<\/div>\n<\/li>\n<\/ul>\n<p>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.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"155_Handle_map_GPS_and_data_updates_separately\"><\/span>15.5 Handle map, GPS, and data updates separately<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>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.<\/p>\n<p>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.<\/p>\n<div><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1522\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_mobpuvmobpuvmobp-1024x559.jpg\" alt=\"Offline First Field Collection Web App PWA GIS interface displayed on a rugged tablet during field data collection\" width=\"1024\" height=\"559\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_mobpuvmobpuvmobp-1024x559.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_mobpuvmobpuvmobp-300x164.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_mobpuvmobpuvmobp-768x419.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_mobpuvmobpuvmobp.jpg 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/div>\n<h1><span class=\"ez-toc-section\" id=\"Summary\"><\/span>Summary<span class=\"ez-toc-section-end\"><\/span><\/h1>\n<p>An offline-first field collection PWA is built around a simple principle: capture and persist locally first; synchronize with the server when connectivity permits.<\/p>\n<p>The main components work together as follows:<\/p>\n<div>\n<table>\n<tbody>\n<tr>\n<th>Component<\/th>\n<th>Responsibility<\/th>\n<\/tr>\n<tr>\n<td>Leaflet<\/td>\n<td>Interactive map, survey context, and GPS marker visualization<\/td>\n<\/tr>\n<tr>\n<td><code>navigator.geolocation<\/code><\/td>\n<td>Obtaining location updates when permitted by the browser and device<\/td>\n<\/tr>\n<tr>\n<td>Service Worker<\/td>\n<td>Serving cached application assets and approved offline map resources<\/td>\n<\/tr>\n<tr>\n<td>Web App Manifest<\/td>\n<td>Defining the application&#8217;s installation metadata<\/td>\n<\/tr>\n<tr>\n<td>IndexedDB \/ LocalForage<\/td>\n<td>Persisting observations, attributes, geometries, and sync status<\/td>\n<\/tr>\n<tr>\n<td>Background Sync \/ online retry<\/td>\n<td>Triggering deferred synchronization where possible<\/td>\n<\/tr>\n<tr>\n<td>Express API<\/td>\n<td>Validating and receiving observations<\/td>\n<\/tr>\n<tr>\n<td>PostgreSQL\/PostGIS<\/td>\n<td>Durable spatial storage, indexing, and analysis<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>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.<\/p>\n<p>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.<\/p>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Building Offline-First Field Collection Web Apps with Progressive Web App (PWA) Architecture Introduction &#8220;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, &#8230; <a title=\"Building Offline-First Field Collection Web Apps with Progressive Web App (PWA) Architecture\" class=\"read-more\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/learn-how-to-build-an-offline-first-field-collection-web-app-pwa-gis-master-leaflet-offline-indexeddb-spatial-storage-and-service-workers\/\" aria-label=\"Read more about Building Offline-First Field Collection Web Apps with Progressive Web App (PWA) Architecture\">Read more<\/a><\/p>\n","protected":false},"author":1,"featured_media":1522,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1,4],"tags":[],"class_list":["post-1376","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","category-gis-articles"],"_links":{"self":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1376","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/comments?post=1376"}],"version-history":[{"count":5,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1376\/revisions"}],"predecessor-version":[{"id":1523,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1376\/revisions\/1523"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/media\/1522"}],"wp:attachment":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/media?parent=1376"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/categories?post=1376"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/tags?post=1376"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}