Frontend
Posting from a shed with one bar of signal: an offline write queue in plain JS
Matt Dev.to (EN Zone)
2 views
I run boatbuildlog.com, a site where people keep a dated log of a boat they are building. Photos, notes and hours, entered day by day over the two or three years a build actually takes.
The important thing about that sentence is where the typing happens. Not at a desk. It happens standing in a metal shed, a barn or a driveway, on a phone, with sawdust on it, on one bar of signal or none at all.
Which makes the ordinary web assumption wrong. Normally a failed POST is an inconvenience and the user retries. Here, a failed POST means someone wrote up an afternoon of work, hit Post, watched it fail, and learned that logging their build is a thing that loses their writing. They do not come back to it. The feature does not degrade, it dies.
So the rule became: a post is never allowed to fail destructively.
The queue
The composer submits through fetch. On any network failure the entry and its photos go into IndexedDB and replay when the connection returns.
IndexedDB rather than localStorage for one reason: Blobs. A log entry is mostly photographs, and localStorage holds strings. Base64ing a few 4 MB phone photos into a 5 MB quota is not a plan. IndexedDB stores the Blob itself, and it survives a browser restart, which matters when the phone gets put in a pocket and the tab is gone by evening.
var DB = 'bbl-offline', STORE = 'entries', VERSION = 1;
function open() {
return new Promise(function (resolve, reject) {
var req = indexedDB.open(DB, VERSION);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
No framework, no wrapper library. The whole site ships hand-written CSS and a handful of plain JS files, because the server it runs on has no Node on it at all and therefore has no build step by design. That constraint turned out to be a gift roughly as often as it was a nuisance.
The part I got wrong first: the CSRF token
The obvious implementation stores the whole form, token included, and replays it verbatim. That works beautifully in testing, where "offline" lasts about eleven seconds.
In the shed it lasts until the drive home. A queued entry can sit for days. By then the CSRF token is stale, the replay is rejected, and the failure looks exactly like a bug in the queue rather than an expired token.
So the token is deliberately not stored with the draft. At replay time the queue fetches the composer page and reads a fresh one out of it:
var m = html.match(/name="_token"\s+value="([^"]+)"/);
if (!m) throw new Error('no csrf token, probably signed out');
That error case is doing real work. If the session expired while the entry sat in the queue, there is no token to find, and the right move is to say so and keep the draft, not to silently discard it. The queue's entire promise is that your writing is still there.
The service worker cache bug that ate two "your fix didn't work" reports
Separate lesson, same project, and this one cost me more.
The service worker is deliberately narrow. It caches the static shell and an offline fallback page. It never caches HTML, because pages here are per-user and moderation-sensitive, and serving one builder a cached page belonging to a different session is a much worse bug than being offline.
The shell is cache-busted with a ?v=<mtime> stamp. The first version of the fetch handler matched on the pathname:
// The bug:
if (SHELL.indexOf(url.pathname) !== -1) {
e.respondWith(caches.match(url.pathname)); // query string thrown away
}
/css/app.css?v=1755 and /css/app.css?v=9902 both normalise to /css/app.css, hit the cache, and return the file from install time. Forever. Every deploy asked for a new URL and got handed the old file anyway.
CSS changes were therefore invisible to anyone who had ever loaded the site once, and perfectly visible to me in a fresh incognito window. I shipped the same fix twice and got told twice that it had not worked.
The fix is to match the full request, so a new ?v= is a deliberate miss:
return cache.match(req).then(function (hit) { // req, not url.pathname
if (hit) return hit;
return fetch(req).then(function (res) {
if (res && res.ok) cache.put(req, res.clone());
return res;
}).catch(function () {
return cache.match(url.pathname); // offline: install-time copy
});
});
Note the fallback still matches on pathname, but only after the network has failed, which is the one moment when "some version of the stylesheet" beats "no stylesheet".
The worst part was not the CSS. The JS files carried no ?v= stamp at all, so they were frozen at install-time versions for every returning visitor, and nobody reports a stale JS file the way they report a stale colour. The one that mattered was the proof-of-work solver on the signup form. Change the challenge server-side and returning visitors keep solving the old one against the new server, and silently cannot register. There is no error to see. Signups just quietly stop.
Now every asset URL goes through one helper and a test fails the build if a reference is added without a stamp. That test exists because I could not trust myself to remember, and the failure mode is invisible.
Takeaways
If losing a user's input is fatal to the habit you are trying to build, the write path needs a queue, not a retry button.
Store Blobs in IndexedDB, not base64 in localStorage.
Do not store CSRF tokens with a queued request. Fetch a fresh one at replay.
A service worker's cache key is the full request. If you cache-bust with a query string and match on pathname, you have built a cache that can never be invalidated.
Assets that fail loudly get reported. Assets that fail silently, like a proof-of-work solver, need a test.
The site is boatbuildlog.com if you want to see what all this is in aid of. If you are building a boat, it is free, and it would like your log.
Read original: https://dev.to/canad1an/posting-from-a-shed-with-one-bar-of-signal-an-offline-write-queue-in-plain-js-4n6p
← Previous
The Fixpoint No Test Suite Can Check
Next →
What to check before installing a Confluence app
Related
how I make my templates easy to reskin (probably overthought this)
Frontend
1
Dev.to (EN Zone)
StyleX won CSS-in-JS because AI agents can read it
Frontend
1
DEV Community
Why I Built a Lightweight Utility Styling Library for React Native (And How It Solves StyleSheet Fatigue)
Frontend
1
DEV Community
DOM in Angular: Understanding the Document Object Model with Practical Examples
Frontend
3
Dev.to (EN Zone)
Comments0
No comments yet — be the first