Most people who keep a log on Boat Build Log write it in a garage, a shed or a boatyard. Signal out there is one bar on a good day. So the site has an offline queue: if you press Post and the request can't get through, the entry and its photos are saved in the browser and sent later, once you're back online.
That queue worked fine for months. Then on 2026-10-27 I posted one entry from the shop and it showed up on my build seven times. Same title, same text, six photos each.
This post is about why that happened, and the two changes that fixed it: a key the server dedupes on, and a Web Lock so only one page drains the queue at a time.
| Piece | Version |
|---|---|
| Laravel | 13 |
| PHP | 8.3+ |
| MySQL | 8 |
| Queue storage | IndexedDB |
| Replay guard | Web Locks API |
How the offline queue works
The composer is a normal Laravel form. A small script, offline.js, hooks it. If the post fails because the phone is offline, it saves the form fields and the photo blobs into IndexedDB:
save: function (form, files) { var fields = {}; new FormData(form).forEach(function (v, k) { // keep the text fields, drop the CSRF token (it will be stale by replay time) if (k !== '_token' && !(v instanceof File)) fields[k] = v; }); return queue.add({ action: form.action, // where to post it later composerUrl: window.location.href, // where to fetch a fresh CSRF token fields: fields, photos: (files || []).map(function (f) { return { blob: f, name: f.name }; }), savedAt: Date.now() }).then(announceQueue); } Later, a drain() walks the queue, replays each record with a fresh token, and removes the record once the server answers OK. offline.js is loaded on every page, so every page load is a chance to drain. That's on purpose: you don't have to go back to the composer for your queued entry to go up.
What actually happened
Here's the sequence, reconstructed from the server logs and the seven rows:
- I posted from the shop. The upload started, the signal dropped, and the composer saved the entry to the queue.
- A minute later I had a bar again. I opened my build page. That page started a drain, and the replay began uploading six photos.
- Before it finished, I tapped over to another page. That page started its own drain. The first upload was still in flight, so the record was still in the queue, so the second page sent it too.
- Repeat for every page I opened over the next few minutes.
Every one of those uploads reached the server. Every one created an entry. Most of the answers never got back to the phone, because I'd navigated away mid-request, so the record never got removed and the next page sent it again.
The guard I had was this:
var draining = false; function drain() { if (draining || !navigator.onLine) return Promise.resolve(); draining = true; // ... replay everything ... } draining is a variable on the page. It knows nothing about the page before it or the tab next to it. So it stopped one page from double-draining, and did nothing at all about the actual problem.
The bigger lesson: the client can't be the only thing that decides whether something was already sent. A response dying on the way back is exactly what a bad connection does. The server got the post fine. It was the phone that never found out.
Fix one: a key per press of Post
The composer page now renders a key into the form. One page, one key, and every retry of that entry carries it:
@csrf {{-- One key per composer page. Every retry of this entry, from this page or from the offline queue, carries it, and the server creates the entry once however many times it arrives. --}} <input type="hidden" name="post_key" value="{{ \Illuminate\Support\Str::uuid() }}"> Since save() copies every non-file field, the key rides along into IndexedDB for free. For drafts queued by an older page that never had a key, the replay gives them one first and writes it back, so the key is fixed before the first replay and kept for every replay after:
function keyed(rec) { if (rec.fields.post_key) return Promise.resolve(rec); rec.fields.post_key = newKey(); // crypto.randomUUID() where available return queue.put(rec).then(function () { return rec; }); // persist it before sending } On the server side it's a nullable column with a unique index scoped to the build:
Schema::table('entries', function (Blueprint $table) { $table->string('post_key', 64)->nullable()->after('source_ref'); $table->unique(['build_id', 'post_key']); // settles two replays racing }); Nullable matters. Entries posted before this change, and entries from the importer, have no key, and MySQL allows any number of NULLs in a unique index.
Answer the repeat like the original
The controller checks for the key before it does anything else. If an entry with this key already exists on this build, it answers exactly as it did the first time and creates nothing:
$key = $data['post_key'] ?? null; if ($key !== null && ($already = $build->entries()->where('post_key', $key)->first())) { // the queue is replaying a post whose answer never reached the phone return $this->posted($request, $build, $already, 'Entry posted.'); } That's why I pulled the response into its own posted() method. The composer posts the text and the first batch of photos, then sends the rest batch by batch, and it needs the entry's id back to do that. A replay has to get the same answer, or the client would hang waiting for something it already has.
The check alone still leaves a gap. Two replays can arrive at the same moment, both look, both find nothing, both insert. That's what the unique index is for:
try { $entry = DB::transaction(function () use ($build, $data, $request, $images, $key) { $entry = $build->entries()->create([ // ... title, body, date, phase, hours ... 'post_key' => $key, ]); // photos are stored only after the row exists // ... return $entry; }); } catch (UniqueConstraintViolationException) { // Two replays in flight at once. The index let one through; this is the other. return $this->posted($request, $build, $build->entries()->where('post_key', $key)->firstOrFail(), 'Entry posted.'); } One detail I like here: the row is inserted before any photo is processed. So the loser of the race fails on the very first statement of the transaction, having written no files and done no image work. Laravel throws UniqueConstraintViolationException for duplicate keys, which keeps the catch specific instead of swallowing every database error.
Fix two: one drain at a time, across tabs
The key stops duplicates. It doesn't stop the phone from uploading the same six photos seven times over one bar of signal, which is its own kind of bad. So the drain now takes a Web Lock:
function drain() { if (draining || !navigator.onLine) return Promise.resolve(); draining = true; var run = (navigator.locks && navigator.locks.request) ? navigator.locks.request('bbl-offline-drain', { ifAvailable: true }, function (lock) { return lock ? drainNow() : null; // another tab or page is already on it }) : drainNow(); // no Web Locks: the per-page flag and post_key still cover it return run.then(function () { draining = false; announceQueue(); }).catch(function () { draining = false; }); } Two things made Web Locks the right tool rather than, say, a flag in localStorage:
-
ifAvailable: truemeans a page that can't get the lock just skips draining. No waiting, no queueing up a second drain behind the first. - The browser drops the lock when the page that holds it goes away. If I navigate off mid-upload, the lock goes with that page, and the next page can pick up the queue. A localStorage flag would sit there "held" by a page that no longer exists.
The MDN page on the Web Locks API covers the options.
Don't forget the service worker
This one bit me for about twenty minutes. The site's service worker keeps a copy of offline.js as an offline fallback. Deploying the new script didn't replace that cached copy, so a phone that loaded the page offline would still get the old, non-deduping drain. Bumping the shell cache name fixed it:
var CACHE = 'bbl-shell-v6'; // was v5: forces the new offline.js into the fallback cache If you change anything a service worker caches, bump the cache. Every time.
Testing it
The feature test reproduces the bug directly: post the same entry seven times with the same key, and every replay has to get the same answer as the first, with one entry and its two photos at the end:
public function test_replaying_the_same_post_creates_it_once(): void { $key = '0b7d2f6e-6d3a-4f43-9a37-2a8c4b1f9e10'; $first = $this->postEntry(['post_key' => $key])->assertRedirect(); foreach (range(1, 6) as $_) { // each replay is answered with the same redirect as the original $this->postEntry(['post_key' => $key])->assertRedirect($first->headers->get('Location')); } $this->assertSame(1, Entry::count()); // not seven $this->assertSame(2, Entry::firstOrFail()->media()->count()); // and not fourteen photos } Around it: a post with no key still posts twice (old clients behave the way they always did), a key on one build doesn't block the same key on another, the chunked path answers a repeat with the existing entry id, and the composer renders a fresh UUID each time.
What I'd tell myself before writing an offline queue
- Assume every request succeeds on the server and fails on the phone. Design for that case first.
- Make the client generate the identity of the thing it's sending, and make the server honour it with a unique index, not just a lookup.
- Answer a repeat exactly like the original.
- Coordinate across tabs with something the browser cleans up for you.
If you want to see what the queue is protecting, a build log on the site is mostly photos taken in places with no signal. Feel free to steal the pattern for your own app, it's about forty lines on each side.


