DEV Community

Talekh Hajiyev
Talekh Hajiyev

Posted on Fully Autonomous

A web app in one offline HTML file: IndexedDB, a localStorage fallback and a hand-written ZIP

I sell a wedding planner that ships as one HTML file. You download it, double-click it, and it runs in a desktop browser with no server, no account and no network. Guest list, seating chart, budget, vendors, timeline, invitations: 17 sections, 9 interface languages, all in a single file of about 1.7 MB (fonts and a few images are inlined as base64).

This post is about the unglamorous parts that made that format work: where the data lives, how it survives a bad day, and how to export a ZIP without a library.

Why a single file at all

The format is the product. The people buying a wedding planner don't want another account, and they'd rather not hand a guest list with names and addresses to a cloud they don't control. A file they own and can copy to a USB stick fits that.

The trade-off: no server means no sync and no "we'll restore it for you". So storage has to be boring and paranoid.

Storage: IndexedDB first, localStorage as a second copy

The whole app state is one plain object. It's serialized to JSON and written to two places on every save:

const DB = {
  db: null,
  open() {
    return new Promise((res, rej) => {
      const r = indexedDB.open(KEY, 1);
      r.onupgradeneeded = e => e.target.result.createObjectStore('kv');
      r.onsuccess = e => { DB.db = e.target.result; res(); };
      r.onerror = () => rej(r.error);
    });
  },
  set(k, v) {
    return new Promise((res, rej) => {
      const tx = DB.db.transaction('kv', 'readwrite');
      tx.objectStore('kv').put(v, k);
      tx.oncomplete = () => res();
      tx.onerror = tx.onabort = () => rej();
    });
  },
  // get / del are the same shape
};
Enter fullscreen mode Exit fullscreen mode

A single key-value object store is enough. I'm not querying guests by index. I load everything once and keep it in memory, so IndexedDB is just a bigger, async localStorage here.

Saves are debounced, and both writes are attempted independently:

function save() {
  S.updatedAt = Date.now();
  clearTimeout(saveT);
  saveT = setTimeout(persist, 350);
}

async function persist() {
  const raw = JSON.stringify(S);
  let idb = false, ls = false;
  try { await DB.set('state', raw); idb = true; } catch (e) {}
  if (raw.length < 4.5e6) ls = writeLocal(raw);   // stay under the ~5 MB quota
  if (idb || ls) showSaved();
  else showNotSavedDownloadBackup();              // loud, clickable warning
}

function writeLocal(raw) {
  try {
    localStorage.setItem(KEY, raw);
    return localStorage.getItem(KEY) === raw;      // read-back check
  } catch (e) { return false; }
}
Enter fullscreen mode Exit fullscreen mode

Two details that mattered:

  • The read-back check. setItem succeeding doesn't guarantee what you read later is what you wrote. Comparing immediately is cheap.
  • Failure is visible. If both writes fail, the "Saved βœ“" indicator turns into a red "Not saved β€” download backup" link. Silent data loss is the worst outcome for this kind of app.

Loading: pick the newest copy that parses

On startup the app reads both copies, throws away anything that doesn't parse or validate, and keeps the most recent one by updatedAt:

async function load() {
  const cands = [];
  try { await DB.open(); const r = await DB.get('state'); if (r) cands.push(r); } catch (e) {}
  try { const r = localStorage.getItem(KEY); if (r) cands.push(r); } catch (e) {}

  const parsed = [];
  cands.forEach(raw => { try { parsed.push(normalizeState(JSON.parse(raw))); } catch (e) {} });

  if (parsed.length) {
    parsed.sort((a, b) => b.updatedAt - a.updatedAt);
    S = migrate(parsed[0]);
  }
}
Enter fullscreen mode Exit fullscreen mode

If IndexedDB is unavailable or its copy is corrupted, localStorage wins, and the other way round too.

Backups are a feature, not a setting

Browser storage can be cleared by the user, by a privacy tool, or by the browser under storage pressure. So the planner treats a JSON backup file as the real source of truth: there's an "Export full backup" button, the last backup date is stored in the state, and the dashboard nags you if you haven't backed up for 14 days.

Importing a backup is the risky path, so it goes through the same normalizeState() as loading:

  • it rejects anything that isn't an object with settings,
  • it rejects backups from a newer schema version instead of half-reading them,
  • it fills missing collections with defaults, so a backup from an older version still opens.

Before replacing the current data, the app stashes the current state under a separate key, so "Restore previous state" can undo an import made by mistake.

A ZIP writer in ~30 lines

One feature exports a personalised invitation PDF for every guest. Downloading 120 PDFs one by one is not an option, and pulling in a ZIP library felt heavy for a single-file app. The ZIP format with no compression ("stored") turns out to be small: a local header per file, a central directory, an end record, and a CRC-32.

const CRC_T = (() => {
  const t = new Uint32Array(256);
  for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
    t[n] = c >>> 0;
  }
  return t;
})();

function crc32(b) {
  let c = 0xFFFFFFFF;
  for (let i = 0; i < b.length; i++) c = CRC_T[(c ^ b[i]) & 255] ^ (c >>> 8);
  return (c ^ 0xFFFFFFFF) >>> 0;
}

function makeZip(files) {               // files: [[name, Uint8Array|string], ...]
  const enc = new TextEncoder();
  const le16 = n => [n & 255, n >> 8 & 255];
  const le32 = n => [n & 255, n >> 8 & 255, n >> 16 & 255, n >>> 24 & 255];
  const parts = [], cd = [];
  let off = 0;

  files.forEach(([name, body]) => {
    const nm = enc.encode(name);
    const data = body instanceof Uint8Array ? body : enc.encode(body);
    const crc = crc32(data);
    // local file header: signature, version, flags (bit 11 = UTF-8 names), method 0 = stored
    const h = new Uint8Array([80,75,3,4, 20,0, 0,8, 0,0, 0,0,0,0,
      ...le32(crc), ...le32(data.length), ...le32(data.length), ...le16(nm.length), 0,0]);
    parts.push(h, nm, data);
    // central directory entry points back to the local header offset
    cd.push(new Uint8Array([80,75,1,2, 20,0, 20,0, 0,8, 0,0, 0,0,0,0,
      ...le32(crc), ...le32(data.length), ...le32(data.length), ...le16(nm.length),
      0,0, 0,0, 0,0, 0,0, 0,0,0,0, ...le32(off)]), nm);
    off += h.length + nm.length + data.length;
  });

  const cdLen = cd.reduce((s, x) => s + x.length, 0);
  const eocd = new Uint8Array([80,75,5,6, 0,0, 0,0,
    ...le16(files.length), ...le16(files.length), ...le32(cdLen), ...le32(off), 0,0]);
  return new Blob([...parts, ...cd, eocd], { type: 'application/zip' });
}
Enter fullscreen mode Exit fullscreen mode

The same function also builds the Excel export: an .xlsx file is just a ZIP of XML files, so the planner writes [Content_Types].xml, the sheet XML and friends as strings and hands them to makeZip().

PDFs are already compressed internally, so skipping deflate costs almost nothing in size. The flag 0,8 (bit 11) marks filenames as UTF-8, which matters when guest names are in Cyrillic, Arabic or Chinese.

The limits are the usual ones for this shortcut: no ZIP64, so stay under 4 GB and 65,535 entries. That's not a problem for wedding invitations.

What I'd tell someone trying this format

  • Keep the state as one serializable object. It makes saving, backups, undo and migrations the same problem.
  • Write to two stores and load the newest valid one. It costs a few lines and covers most "my data is gone" cases.
  • Version your state from day one (v: 2 in my case) and refuse to read backups from the future.
  • Make save failures loud. Offline apps have no support team behind them.
  • Test on file://. Some APIs behave differently when the page has no real origin, so test the downloaded file itself, not only a dev server.

If you want to see how this feels in practice, there's a live demo with exports disabled. The product itself is SpectrTech Wedding Planner, a one-time purchase.

This article was written with the help of AI. The code excerpts are from the shipping product, lightly reformatted for reading.

Top comments (0)