Building an Offline Location Journal That Recovers from Interrupted Uploads
Locator pairs atomic local photo capture with stable submission IDs and staged cloud saves to recover from interrupted uploads. A personal project with automated recovery checks.
- Client
- Locator — personal project
- Focus
- Web Application Engineering
- Year
- 2026
- Reading time
- 8 min read • 1557 words
Key outcomes
Stable retry identities, staged cloud saves, and atomic local acknowledgement implemented.
- Implemented atomic local queuing of entries and original photo blobs.
- Implemented stable retry identities and staged attachment finalization.
- Retained owner-scoped drafts through authentication recovery.
- Implemented a transactional outbox for retryable photo cleanup.
- Offline
- IndexedDB
- Upload recovery
- Idempotency
- Supabase
- PWA
Locator is my personal location and photo journal, built into my portfolio’s private admin area. I owned its design, implementation, and verification. It captures a place, a note, and attached photos, saves them on the device, and synchronizes them when an authenticated connection is available.
The central engineering problem was deciding when a save was complete. An upload can stop after the server creates a record, after one photo arrives, or after the server finishes but before the browser receives confirmation. Locator keeps a stable submission identity through those interruptions and retains the local entry until cloud persistence is confirmed.
The design centers on three decisions: save the full capture locally, keep one identity through every retry, and clear the local queue only after the cloud record is confirmed. Automated checks exercise those recovery paths, including interrupted uploads and retries after a lost response.
Make the local save meaningful
A location journal needs to preserve more than coordinates. A note without its intended photos is an incomplete capture, and an optimistic success message cannot recover a file that was never stored.
Locator assigns the entry a UUID before writing it to IndexedDB. The pending entry and its original photo blobs enter the database in one transaction. The form clears only after that transaction completes. If local storage fails, the form retains the current note and photos and surfaces the error.
That separates two useful states for the person using it: saved locally and synchronized to the cloud. A successful local save can survive a normal database close and reopen without pretending the network work has finished. Pending entries remain visible with synchronization status, so a failed upload becomes work to retry.
This choice also creates a boundary. Pending originals occupy browser storage, which is unencrypted and subject to browser eviction. Clearing local data or explicitly signing out can remove unsynchronized work, so those controls warn about the loss. Local persistence supports interruption recovery; it is not a backup.
Keep one identity across every retry
Retrying a request is straightforward. Retrying a partially completed operation requires agreement between the browser, database, and object store.
Locator uses a staged save. Its begin operation creates the location and registers the attachment IDs, with each storage path derived from the owner, entry, and photo IDs. The location initially remains unready. Upload retries write to those same paths instead of creating new attachment names.
The begin operation also checks that an existing UUID still represents the same submission. It compares the owner, coordinates, accuracy, capture time, note, and attachment list. After checking ownership, the database function applies this payload consistency check:
IF item.latitude IS DISTINCT FROM (payload->>'latitude')::float8
OR item.longitude IS DISTINCT FROM (payload->>'longitude')::float8
OR item.accuracy_meters IS DISTINCT FROM (payload->>'accuracy_meters')::float8
OR item.captured_at IS DISTINCT FROM (payload->>'captured_at')::timestamptz
OR item.note IS DISTINCT FROM payload->>'note'
OR item.attachment_ids IS DISTINCT FROM photo_ids THEN
RAISE EXCEPTION 'Idempotency key conflicts with a different entry';
END IF;This excerpt from the begin function makes the contract explicit: a retry can repeat the submission, but it cannot quietly change what that identity means. The same UUID with a different note or attachment list is a conflict, not another version of the save.
Once the uploads return successfully, a finish operation checks that every registered attachment has a corresponding storage object. Only then does it mark the location ready. Synchronized history and map views use ready entries; unfinished cloud records have a separate incomplete-upload treatment.
There is no single transaction spanning the device, database, and photo storage. The staged state makes partial progress explicit and gives retries something stable to reconcile against.
The browser keeps the queued entry until it reads back the ready record. A retry uses the same entry ID, and the begin RPC tells the browser whether it can skip uploading photos.
sequenceDiagram
accTitle: Saving and retrying a Locator entry
accDescr: The browser queues an entry and its photos in IndexedDB. An unfinished entry needs photo uploads. A ready entry skips uploads on retry. The browser clears the queue only after reading the ready record.
participant B as Browser
participant D as Postgres
participant A as Upload API
participant S as Storage
B->>B: Queue entry ID and photos
B->>D: Begin with same entry ID
D-->>B: Return ready status
alt Entry still staged
B->>A: Upload each photo
A->>S: Upsert at registered path
A-->>B: Confirm upload
else Already ready on retry
Note over B,D: Skip photo uploads
end
B->>D: Finish entry
Note over D: Check objects and mark ready
B->>D: Read ready record
D-->>B: Return ready record
B->>B: Cache record and clear queueEach step has its own completion boundary. In particular, a ready cloud record and a cleared local queue are separate states. That distinction is what makes a lost response recoverable.
Recover when confirmation disappears
The most revealing failure occurs when the cloud save succeeds but its response never reaches the browser. The device cannot safely infer success from silence, yet submitting a fresh entry would risk creating a duplicate.
Locator retains the queued submission and retries its original UUID. If the begin operation recognizes that the matching entry is already ready, the client skips photo uploads. It then finalizes and reads back the ready record before acknowledging it locally.
That acknowledgement is another IndexedDB transaction: cache the synchronized metadata, remove the pending submission, and delete its pending photo blobs together. The queue is cleared only after confirmed persistence and a completed local acknowledgement. If local acknowledgement fails, the stable identity still gives the next retry a way to recognize the cloud record.
A missing local attachment blocks finalization for an unfinished entry. The client reports that the entry has been retained for recovery. An already-ready cloud entry can be acknowledged without its local photo blobs because the server has already confirmed their presence.
This is entry-level recovery. An unfinished submission retries its registered photos, including photos that may already have arrived. It does not resume a large file from a byte offset. For Locator’s bounded attachments—up to eight JPEG, PNG, or WebP originals, each at most 4 MiB—that keeps the recovery protocol compact, at the cost of potentially retransmitting completed photos.
Recover under the correct account
Keeping drafts through a network failure would be insufficient if reconnecting could upload them under whichever account happened to be active.
Local entries and photos carry an owner ID, and application reads filter by that owner. Synchronization checks the authenticated user before beginning remote work and checks again before finalization. The server independently enforces account authorization, row ownership, and permitted storage paths.
If authentication expires, the application locks the account view while retaining queued data. Signing back into the same account allows recovery. Switching accounts hides the previous account’s queue instead of assigning it to the new session. Remembering the last verified identity supports offline use, but grants no authority to write to the cloud.
Foreground synchronization runs when the app opens or resumes, connectivity returns, or the user selects Sync Now. Web Locks serialize it across supported tabs, while database idempotency protects the record identity when those locks are unavailable. The service worker can ask active clients to sync; it holds no credentials and does not upload independently. A queued capture therefore needs the application to be open again to complete synchronization.
Treat cleanup as unfinished work too
The same interruption problem appears during deletion. Removing a location’s database rows does not itself remove physical photo objects.
Locator queues photo cleanup tasks transactionally before metadata deletion cascades. Foreground synchronization removes those objects through the Storage API and acknowledges each cleanup task afterward. A failed removal leaves the task available to retry.
If a staged cloud upload loses its local draft, History exposes it as incomplete with a Delete action. The interface does not imply that missing originals can be reconstructed. Old staged entries and cleanup tasks still need operational attention, particularly when an account loses authorization.
Exercise the failure paths before device rollout
The recovery checks focus on the moments when the client has an incomplete picture of the save: an upload stops, a local attachment is missing, or the cloud record is ready but its confirmation has been lost. Isolated tests use simulated IndexedDB and remote test doubles to exercise queue persistence and retry decisions, including the shortcut that skips uploads for an already-ready record.
The October 2026 project verification also records authenticated Auth, Storage, database, and Chromium checks using temporary accounts. The browser suite injects an upload failure, then checks that retrying the same UUID produces one ready record. Session recovery and account-switching scenarios exercise whether retained work stays attached to its owner. These checks use synthetic GPS and fixture images rather than physical sensors.
The next acceptance milestone is the deployed HTTPS upload path followed by real Android and iOS use: GPS and camera capture, Home Screen installation, offline restart, authentication renewal, and an application update with photos still queued. That extends the automated scenarios to the device and network conditions the journal will encounter in everyday use.
For another small offline application with bounded attachments, the transferable decision is to define completion across every persistence boundary. Keep the original identity, retain local work until acknowledgement, and give partial cloud state an explicit recovery or deletion path. If the product needs large-file resume, encrypted local records, or dependable uploads while closed, it needs additional mechanisms beyond this design.