DEV Community

Cover image for Offline-first in the browser. How I keeping PWA and AI jobs alive across a refresh
Wlad Radchenko
Wlad Radchenko

Posted on

Offline-first in the browser. How I keeping PWA and AI jobs alive across a refresh

A user types a prompt, clicks generate, and while the model is working they close the tab by accident. Or the wifi drops. Or they just hit refresh out of habit. In a lot of web apps, that is the end of the job and sometimes the end of their unsaved work too. The request was living in a variable in memory, and memory is gone.

The browser is a hostile place to keep state. Tabs close, networks flap, the page reloads on a whim. An editor that loses work on a refresh feels broken even when every other part is polished. This is how I think about durability in Wunjo Design, where the editor itself runs offline and only AI generation and cloud sync need the network. The snippets below are minimal and generic, just enough to show the shape.

A laptop with code on a desk
The refresh is the cheapest test of whether your app respects the user's work. Photo: Unsplash.

Two things have to survive

When you say "offline-first," people hear "cache the assets." That is the small part. The real work is keeping two kinds of state alive across a reload:

The working document, so the canvas comes back exactly as the user left it. And in-flight AI jobs, so a generation that was running before the refresh is still running, or already done, when the page comes back. Lose either and the user feels the floor move.

The mental model I use is a kitchen ticket rail. When an order comes in, you clip the ticket to the rail before you start cooking. If the cook walks away, the ticket is still there for whoever comes back. The order does not live in the cook's head. It lives on the rail.

The rail is IndexedDB, not localStorage

localStorage is the wrong tool here: it is synchronous, small, and string-only, so it blocks the main thread and chokes on real documents. IndexedDB is the right one: asynchronous, large, and it stores structured data and blobs. A thin wrapper keeps it readable.

import { openDB } from 'idb'

const db = await openDB('editor', 1, {
  upgrade(db) {
    db.createObjectStore('docs')                 // the working document
    db.createObjectStore('jobs', { keyPath: 'id' }) // AI generation jobs
  },
})
Enter fullscreen mode Exit fullscreen mode

Autosave the document on a throttle, not on every keystroke, so you are not hammering the disk:

const save = throttle(async () => {
  await db.put('docs', encodeDoc(), 'current')
}, 1000)
Enter fullscreen mode Exit fullscreen mode

On startup, the first thing the app does is read docs/current back and rehydrate the canvas. The user never sees a blank page after a reload.

The job has to exist before the network call

This is the part people skip, and it is the whole point. If you call the AI endpoint first and plan to record the job when it answers, a refresh in between leaves you with a charge and no record. So you write the job down first, in IndexedDB, as a stub, and only then talk to the network.

// 1. record intent BEFORE the request
const job = { id: uuid(), prompt, status: 'queued', createdAt: Date.now() }
await db.put('jobs', job)

// 2. submit, then persist the remote handle
const { remoteId } = await api.submit(prompt)
await db.put('jobs', { ...job, status: 'running', remoteId })
Enter fullscreen mode Exit fullscreen mode

Now the job is durable. The browser can die at any line and the worst case is a job stuck in queued or running, which is recoverable, instead of a job that silently vanished.

Resume is just reading the rail on boot

Because every job is a row in IndexedDB with a status, resuming after a reload is not special logic. It is a query.

async function resumeJobs() {
  const jobs = await db.getAll('jobs')
  for (const job of jobs) {
    if (job.status === 'running') pollUntilDone(job)   // pick up where we left off
    if (job.status === 'queued')  submitAndRun(job)    // never actually started
  }
}
Enter fullscreen mode Exit fullscreen mode

One observer watches job state and updates the UI, so the canvas does not care how a job finishes, only that its status changed:

class JobObserver {
  constructor() { this.listeners = new Set() }
  subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn) }
  async update(job) { await db.put('jobs', job); this.listeners.forEach(fn => fn(job)) }
}
Enter fullscreen mode Exit fullscreen mode

That decoupling matters. "Is the job done" lives in one place; "what the canvas shows" lives in another; they talk through status changes. You can close the panel, reopen it, refresh the page, and the UI rebuilds itself from the persisted jobs every time.

Keep the main thread for drawing

Persisting, encoding, exporting, and diffing are not free, and the one thread you cannot afford to block is the one painting the canvas at 60 frames a second. Push the heavy work to a worker.

const worker = new Worker('./export.worker.js', { type: 'module' })
worker.postMessage({ type: 'export', doc: encodeDoc() })
worker.onmessage = (e) => downloadBlob(e.data.blob)
Enter fullscreen mode Exit fullscreen mode

The editor stays responsive while a large export or a re-encode runs off to the side. Offline-first and smooth are not in tension; they both come from treating the main thread as sacred and everything else as background work.


Wlad Radchenko About the author. I'm Wlad Radchenko, a software engineer. This article draws on building Wunjo Design, a browser-based AI vector editor that works offline, and the open-source Wunjo Make. Get in touch to find more on GitHub and LinkedIn.

Things that bite you in practice

Make resume idempotent. A generation costs money, so resuming a running job must re-attach to the existing remote job by its remoteId, never submit a fresh one. The "write the stub first" rule is what makes this possible: you always have the handle.

Respect the quota. IndexedDB has limits and the browser can evict your data under pressure. Store images as Blobs, not base64 data URLs, which inflate size by about a third, and prune finished jobs on a schedule.

Version your schema. The upgrade callback runs when you bump the database version. Plan migrations now, because shipping a change that silently drops a user's stored documents is the one bug you cannot apologize your way out of.

Treat the network as optional. Writes go to local storage first and sync when a connection exists. That is the local-first stance Kleppmann and colleagues argued for in 2019, and in a creative tool it is the difference between "my work is safe" and "I hope the save went through."

What this buys the product

The editor in Wunjo Design opens and works with no connection at all; only AI generation and cloud storage reach for the network. Your document survives a refresh, and a generation you kicked off is still there when you come back. That reliability is not a feature on the pricing page. It is the absence of a specific bad feeling, the one where you close a tab and your stomach drops. Building for the refresh is how you make sure that feeling never happens.

References

  • Kleppmann, Wiggins, van Hardenberg, McGranaghan. "Local-first software: you own your data, in spite of the cloud." ACM Onward! 2019. PDF
  • MDN Web Docs. "IndexedDB API" and "Using Web Workers." developer.mozilla.org

Top comments (0)