I'm building a small web app where users practice exam questions. While practicing, they build up two lists: questions they bookmarked and questions they got wrong. Both are just arrays of question IDs.
Requirements:
- Works without an account, so lists live in
localStorage - After login, lists follow the user across devices
- No framework and no sync library. Vanilla JS on the front end, and a backend endpoint that stores whatever JSON you send it
That last point matters. The server endpoint is deliberately simple:
GET /sync → { bookmarks: [...], mistakes: [...] }
POST /sync ← { bookmarks: [...], mistakes: [...] } // overwrites everything
No per-item operations, no timestamps, no conflict detection. Every POST replaces the whole snapshot. All the sync logic lives on the client.
This post covers what that client logic needs to get right. "Read from localStorage, POST it to the server" turns out to lose data in at least five different ways.
1. Keep localStorage boring and defensive
Everything starts with reading lists safely. localStorage can contain anything: malformed JSON from an old version, null, numbers where you expected strings, or duplicates.
function normalizeIdList(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
const out = [];
for (const id of value) {
if (id === null || id === undefined) continue;
const s = String(id).trim();
if (!s || seen.has(s)) continue; // drop empties and duplicates, keep first-seen order
seen.add(s);
out.push(s);
}
return out;
}
function readIdList(key) {
try {
return normalizeIdList(JSON.parse(localStorage.getItem(key) || '[]'));
} catch {
console.warn(`Invalid saved list for ${key}; using an empty list.`);
return [];
}
}
Every read and every write goes through normalizeIdList. That gives you one canonical form, so comparing two lists is just an element-by-element check:
const listsEqual = (a, b) => {
const x = normalizeIdList(a), y = normalizeIdList(b);
return x.length === y.length && x.every((id, i) => id === y[i]);
};
2. Coalesce writes: one request in flight, always send the latest state
A user can bookmark three questions in two seconds. Firing three POSTs creates two problems:
- They can arrive out of order, and since every POST overwrites everything, an older snapshot that arrives last wins.
- It's wasteful.
The fix: allow only one write in flight. While it runs, more change requests only set a flag. When the request finishes, if the flag is set, send the current state again.
let writePromise = null;
let writeRequested = false;
function syncToCloud() {
if (!user.isLoggedIn) return Promise.resolve();
writeRequested = true;
if (writePromise) return writePromise; // a loop is already running; it will pick this up
const flush = async () => {
while (writeRequested && user.isLoggedIn) {
writeRequested = false;
const payload = {
bookmarks: readIdList('bookmarks'), // snapshot taken NOW, not when requested
mistakes: readIdList('mistakes'),
};
try {
await fetch('/sync', { method: 'POST', headers: authHeaders(), body: JSON.stringify(payload) });
} catch {
console.warn('Cloud sync failed; local data is preserved.');
}
}
};
writePromise = flush().finally(() => {
writePromise = null;
if (writeRequested && user.isLoggedIn) syncToCloud(); // a request slipped in at the very end
});
return writePromise;
}
Because the payload is built inside the loop, every request carries the newest data. Ten quick changes turn into at most two requests: the one already in flight, plus one with the final state.
3. A revision counter: "did anything change while I was waiting?"
Coalescing handles write-vs-write. The harder race is read vs. write.
On login, the client GETs the server's lists and merges them in. But the GET takes time. If the user bookmarks something while the GET is in flight, and the response then gets merged on top of local state, the new local change can be lost or reordered unpredictably.
The fix is a counter that increases on every local change:
let revision = 0;
function writeList(key, ids, { markMutation = true } = {}) {
const next = normalizeIdList(ids);
const changed = !listsEqual(readIdList(key), next);
localStorage.setItem(key, JSON.stringify(next));
if (changed && markMutation) {
revision++;
localStorage.setItem('sync_pending', '1'); // see section 4
}
return changed;
}
The read path remembers the revision when it starts, and checks it before merging:
async function syncFromCloud() {
if (!user.isLoggedIn) return;
const revisionAtStart = revision;
const wasPending = localStorage.getItem('sync_pending') === '1';
const res = await fetch('/sync', { headers: authHeaders() });
if (!res.ok) return;
const remote = await res.json();
if (revision === revisionAtStart && !wasPending) {
// Nothing changed locally during the GET: safe to merge.
mergeRemote(remote);
} else {
// Local state changed while we waited. Local is newer, so push it.
syncToCloud();
}
}
The write path uses the same counter to decide whether it's safe to say "we're in sync":
const revisionForPayload = revision;
const response = await fetch('/sync', { method: 'POST', /* ... */ });
if (response.ok && revisionForPayload === revision && !writeRequested) {
localStorage.removeItem('sync_pending'); // only clear if nothing changed after the snapshot
}
Without that check, you can clear the "pending" flag for a snapshot that's already stale.
4. A pending flag that survives reloads
The revision counter lives in memory. It's gone when the user closes the tab. So the "I have changes the server hasn't seen" state also needs to be stored in localStorage:
- Set
sync_pending = '1'on every real local change - Clear it only after a successful POST of an up-to-date snapshot
- On the next page load, if it's still set, don't merge server data over local data. Push local data first
This covers the most common data loss scenario: the user makes changes offline, or the POST fails, and they close the tab. Next time, the app loads stale server data. Without the persisted flag, a merge would treat the server as the source of truth, and the offline changes would compete with older data.
5. Merging: union, and push back if the server is behind
When it's safe to merge (nothing pending, nothing changed during the GET), the merge is a set union that keeps order:
function mergeRemote(remote) {
const remoteBookmarks = normalizeIdList(remote.bookmarks);
const merged = normalizeIdList([...readIdList('bookmarks'), ...remoteBookmarks]);
// This write came from the server; don't mark it as a pending local change.
writeList('bookmarks', merged, { markMutation: false });
// If local had items the server didn't, push the merged result back.
if (!listsEqual(remoteBookmarks, merged)) syncToCloud();
}
Union is the right default for the main use case. A guest bookmarks 10 questions, then logs in to an account that already has 30 from another device. They expect 40, not 10 and not 30.
Note markMutation: false. Writing server data locally isn't a user change, so it shouldn't bump the revision or set the pending flag. Otherwise every sync would trigger another sync.
6. Account switching: don't merge one person's data into another's
Union has a nasty side effect on shared devices. Alice logs out, Bob logs in on the same browser, and the merge happily uploads Alice's bookmarks into Bob's account.
The fix is to remember which account the local lists belong to:
function onLogin(nextUser) {
const nextId = String(nextUser.id);
const ownerId = localStorage.getItem('sync_owner_id') || '';
const previousId = user.id ? String(user.id) : '';
const accountChanged = (ownerId && ownerId !== nextId) || (previousId && previousId !== nextId);
if (accountChanged) {
localStorage.removeItem('bookmarks');
localStorage.removeItem('mistakes');
localStorage.removeItem('sync_pending');
revision++;
}
localStorage.setItem('sync_owner_id', nextId);
user = { ...nextUser, isLoggedIn: true };
syncFromCloud();
}
A guest's lists have no owner yet, so they merge into the first account that logs in, which is the behavior you want. Once lists belong to someone, they never leak into a different account.
7. Evolving the stored format without breaking old clients
Eventually we needed to know where a mistake came from, practice or mock exam, so a question could be in both lists independently. Plain IDs became prefixed keys:
"q123" → legacy format (no source)
"practice::q123"
"mock::q123"
Two rules kept this safe:
-
Readers accept both formats. A key without
::is parsed as{ id: key, source: 'legacy' }. - Writers upgrade on touch. When a question is added or removed as a practice mistake, any legacy entry for the same ID is removed and replaced with the new key.
No migration script and no flag day. Old data upgrades itself as people use the app, and the server never needs to know the format changed, because it just stores arrays of strings.
The bug this design still has: deletions come back
Here's the honest part. A union merge can't represent deletions.
- Device A and Device B both have
[q1, q2]. - On Device A, the user answers q1 correctly and it's removed from Mistakes. A pushes
[q2]. The server now has[q2]. - Device B was offline the whole time, with no pending changes. It loads, sees nothing pending, and merges:
union([q1, q2], [q2]) = [q1, q2]. - Device B decides the server is behind and pushes
[q1, q2]. q1 is back, on every device.
The same thing happens with a "reset everything" button. The device that clears its data pushes empty lists, then any other device with stale data merges its lists back in on the next load.
The pending flag protects the device that made the change, but not the other devices. Union says "keep everything either side has," which by definition can't express "the user removed this."
How to fix it, from simplest to most robust
1. Tombstones. Store deletions as data:
{ items: ['q2'], removed: { q1: 1696500000000 } } // id → deleted-at timestamp
The merge becomes union(items) − removed. Prune tombstones after a reasonable window, such as 90 days.
2. Per-item timestamps. Store { id, addedAt, removedAt } for every item and keep the most recent event per ID. It's a last-writer-wins element set, a simple CRDT.
3. Server-side operations. Replace "POST the whole snapshot" with POST /sync/ops [{ op: 'add', id }, { op: 'remove', id }], and let the server apply them in order. This needs a slightly smarter backend, but deletions become easy.
For a list of bookmarks, tombstones are usually enough, and they're a backward-compatible change to the payload.
The UX lesson
Since a destructive action syncs everywhere, it should say so. A reset button labeled "deletes progress only" that actually clears synced lists too is a trust problem, not just a bug. When local data is backed by a server, a "clear" action has to be honest about its scope: this device only, or every device.
Checklist
- [ ] Every read and write goes through one normalizer (parse safely, dedupe, keep order)
- [ ] One write in flight at a time, with the payload built from current state when it's sent
- [ ] A revision counter checked before merging a GET response and before clearing "pending"
- [ ] A persisted pending flag, so unsynced local changes survive reloads and failed requests
- [ ] A union merge for the "guest data + account data" case, pushed back when the server is behind
- [ ] Server data written locally doesn't count as a local change
- [ ] Local data is tied to an owner account and cleared when a different account logs in
- [ ] Readers accept old formats, and writers upgrade keys when they touch them
- [ ] Deletions are represented explicitly (tombstones or timestamps), or you accept that they can come back
- [ ] Destructive actions say clearly whether they affect this device or every device
This came from building HVAC Exam Master, an exam-prep web app for HVAC technicians. The front end is vanilla JS and the backend is a REST endpoint, which is why all the sync logic ended up on the client.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.