← Back to blog

Private by Default, Available Everywhere: Balancing Privacy, Integrity, and Availability in a Local-First AI App

Why shouldn't we start building apps that are fully private without compromising data integrity and availability?


An AI assistant that remembers you is only as good as the trust it keeps. It holds the most sensitive data a product can touch — your goals, your habits, your conversations, the things you’d hesitate to say out loud.

So before you ship one feature, you have to answer a hard question: where does all of that live, and who can read it?

That question isn’t one decision. It’s three, pulling in different directions at once:

  • Privacy — the data shouldn’t sit readable on someone else’s server.

  • Integrity — the data that comes back must be the data that was saved, uncorrupted, every time.

  • Availability — the data has to follow the user across devices, instantly, with no ceremony.

You can’t have all three perfectly. But you can engineer a solution that gets as close as the tradeoffs allow — and that’s what this article is about.

I built this exact system for a local-first assistant (continuing the consultant example from the previous article “How to Build an AI That Actually Remembers: A Memory-Retrieval Blueprint You Can Copy” — same fictional user, “Carol,” same no-product-naming rule). Here’s the architecture, the honest tradeoffs, and the parts I think are worth stealing.


It’s a tradeoff, not a checklist

The three forces sit in tension. Pull one up, the others pay.

Four ways to store user data

The four realistic options:

  • A · Local only. Everything stays on the device. Maximum privacy, zero availability — wipe the browser and it’s gone.

  • B · Plaintext cloud database. Maximum availability, zero privacy — anyone with database access reads everything.

  • C · End-to-end encrypted. The client encrypts with a user passphrase before upload. Maximum privacy and availability, but real friction: a forgotten passphrase means lost data, and there’s no recovery.

  • D · Server-managed encryption. The server encrypts at rest with its own keys. No passphrase, no friction, and a database dump reveals nothing — but the provider could decrypt it.

There is no right answer in the abstract. There’s only the answer for your product. And here’s the uncomfortable truth I think too many founders dodge:

Most users don’t care where their data lives — they care whether the app works. The core solution is what makes them stay. Privacy is what makes a few of them choose you over the alternative.

So privacy isn’t a religion. It’s a competitive lever you pull deliberately, with your eyes open about what it costs. The rest of this article is about pulling it well.


Pillar 1 — Privacy: start local-first

The cheapest privacy win is also the biggest one: don’t send the data to a server in the first place.

Store everything in the browser’s own database (IndexedDB), and make the app fully functional offline. The assistant reads, writes, and reasons over local data with no round-trip and no copy living in your cloud.

import { openDB } from 'idb';
const SCHEMA_VERSION = 22;
async function open() {
  return openDB('assistant', SCHEMA_VERSION, {
    upgrade(db, oldVersion, _newVersion, tx) {
      // Idempotent: only create what's missing
      if (!db.objectStoreNames.contains('facts')) {
        db.createObjectStore('facts', { keyPath: 'id' });
      }
      // Versioned: run once, only when coming from an older version
      if (oldVersion < 12 && db.objectStoreNames.contains('insights')) {
        tx.objectStore('insights').createIndex('byConfidence', 'confidence');
      }
    },
  });
}

Local-first buys you three things at once: privacy (no cloud copy), speed (no latency), and resilience (works offline). But it has one fatal flaw: it’s fragile. A cleared browser, a dead laptop, a new device — and everything is gone.

That’s why privacy alone isn’t a strategy. It has to be paired with a safe way to make the data available everywhere.


Pillar 2 — Availability: the encrypted cloud

Availability means the data has to leave the device. The question is in what form.

Option A (local only) fails here by definition. Option B (plaintext cloud) is what “just sync to Postgres” gets you — and it’s the exact thing you want to avoid: data sitting open, readable by anyone with a database credential or a leaked backup.

So the real decision is C vs D — who holds the encryption keys.

C — End-to-end encryption. The client encrypts with a key derived from a user passphrase; the server stores ciphertext it can never read. This is the gold standard for privacy. The cost is entirely UX: passphrase setup, passphrase recovery, the “I forgot my password and lost years of data” support ticket. For a productivity app where the whole point is that data follows you frictionlessly, that cost is steep.

D — Server-managed envelope encryption. The server encrypts the snapshot at rest with keys it manages. There’s still no passphrase, no friction — the user signs in and everything is just there. A database dump exposes ciphertext, not readable data; the encryption is real and meaningful for the most common threats (a stolen backup, an over-broad database grant, an auditor looking at raw rows). The honest caveat is that the provider could decrypt, because the provider holds the keys.

The answer is: either C or D, depending on the use case. Both are legitimate — the deciding variable is your users’ threat model and how much friction the core experience can absorb.

  • Go with D (server-managed encryption) when zero-friction availability is the whole point. The user signs in and everything is just there. It protects against a stolen backup, an over-broad database grant, an auditor reading raw rows — but not against the operator. Most consumer productivity tools live here.

  • Go with C (end-to-end encryption, a user passphrase) when the data is sensitive enough that even the operator must not read it — health records, legal work, confidential client material. The user holds the key; the cost is passphrase setup, key recovery, and the “I lost my passphrase and my data” support burden.

There’s no universally right choice — and that’s the point. C and D are both defensible; what matters is that the tradeoff is deliberate, documented, and chosen for your users — not “we just put it in a database.” The rest of the architecture — the sync loop, the migration discipline, the integrity layers — is identical either way.

Whatever you pick, the transport and the storage are separate concerns: send over HTTPS, encrypt at rest, and never expose plaintext to anything that doesn’t strictly need it.


Pillar 3 — Integrity: make corruption rare

Privacy and availability are pointless if the data is wrong. Integrity isn’t one feature; it’s five independent layers that each catch a different failure.

Integrity is defense in depth

Layer 1 — Content hashing. SHA-256 the serialized data. This gives you cheap change detection: if the hash hasn’t changed, nothing changed, so don’t upload anything. It also gives you a fingerprint to verify a download is what it claims to be.

async function sha256(text: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return btoa(String.fromCharCode(...new Uint8Array(buf)));
}

Layer 2 — Schema validation. Never trust data on the way in — from a cloud pull, a file import, or a restore. Validate every record against a generated schema before it touches the store.

function validateRecord(kind: string, value: unknown) {
  const error = checkAgainstSchema(SCHEMA[kind], value);
  if (error) throw new Error(`Invalid data: ${error}`);
}

Layer 3 — Referential integrity. A child record must point at a real parent. A message belongs to a conversation that exists; a milestone belongs to a roadmap that exists. Reject duplicate IDs. Reject dangling references.

Layer 4 — Concurrency control. Two devices editing at once is the classic integrity killer. Guard every push with an If-Match ETag; if the server says "412, it changed under you," re-pull, merge, and retry instead of silently overwriting. And when a record is deleted, write a tombstone — a marker that says "this was deleted" — so the deletion propagates to every device instead of resurrecting as a stale copy.

async function remove(id: string) {
  await db.put('tombstones', { id, deletedAt: Date.now() }); // so delete syncs everywhere
  await db.delete('facts', id);
}

Layer 5 — Write guards. During a restore or a full delete, any concurrent write is a corruption risk. Refuse writes in those windows rather than let them interleave.

None of these is exotic. Together they turn “data loss” from a routine hazard into a rare, contained event.


The engine that ties it together

The three pillars meet in one loop: the sync engine. It’s the thing that keeps the local database and the encrypted cloud snapshot converged — without the user ever thinking about it.

The sync cycle

The shape of it:

  • Local changes are debounced (a burst of edits becomes one cycle) and hashed; if the content hash is unchanged from the last upload, nothing is sent at all.

  • Remote changes are detected by a cheap status poll that only compares an ETag; the full body is downloaded only when another device actually changed something.

  • Every push carries If-Match; a 412 (a concurrent device pushed first) triggers a bounded re-pull + merge + retry, so no device's data is ever clobbered.

  • A successful merge updates the UI in place — no hard reload.

async function push(snapshot: string, etag: string | null) {
  const res = await fetch('/api/sync', {
    method: 'POST',
    headers: { 'If-Match': etag ?? '' }, // optimistic concurrency
    body: snapshot,
  });
  if (res.status === 412) throw new ConflictError(); // someone else pushed — merge, retry
  return res.headers.get('ETag');
}

The elegance here is that convergence is built from a few cheap primitives — hashing, ETags, tombstones, retries — rather than a heavyweight sync framework. You can build this yourself in a weekend.


The hard part: schema changes

Here’s the piece that quietly decides whether your “available everywhere” promise survives your first feature release: migrations.

The moment you ship, real users have data in an old shape. The moment you add a field, rename a store, or split a nested array into its own table, that old data must be rewritten — on every device, in the background, without the user seeing an error.

The mechanism is the IndexedDB version number, and the discipline is idempotence.

Schema migration on every app open
  • Bump a version number; the browser runs your upgrade callback on open, comparing the stored version to the new one.

  • Guard every change with “only if it doesn’t already exist” so the migration can safely re-run.

  • Version-gate structural changes (if oldVersion < 12) so each step runs exactly once.

  • Promote data in place — moving nested arrays into their own store happens inside the same atomic transaction, not as a separate pass that could half-complete.

  • Adopt legacy data once — a one-time, idempotent, background copy that can be retried if it’s interrupted.

The result: a user who hasn’t opened the app in six months opens it and — instantly — has the current schema with all their data, no error dialogs, no “your data is being migrated” spinner. The migration happened before the first frame rendered.

The one rule that makes all of it safe: never let a migration be destructive or non-idempotent. If a step can’t safely run twice, it can’t safely run at all.


Testing: why errors are rare, not lucky

None of the above survives contact with real devices unless you test the failure paths, not just the happy path.

The tests that matter most here aren’t the “does the happy path work” ones. They’re the ones that simulate everything going wrong:

  • Schema-compatibility tests — an old backup file, from an old version, must still validate and restore against today’s schema. This is the test that catches the migration you’ll regret three releases later.

  • Sync and merge tests — two devices, conflicting edits, interrupted uploads, concurrent pushes. Assert that no data is lost and no device is clobbered.

  • Deletion-propagation tests — a delete on one device must reach every device, tombstone and all.

  • Migration tests — open an old-version database, run the upgrade, assert the data landed in the right shape with the right indexes.

  • Import/restore tests — feed the validator malformed, duplicate, and malicious input and assert it rejects every one.

The goal is to make these failures rare because they were found in CI, not rare because you got lucky. A data-migration bug that ships to a thousand devices is nearly impossible to fix retroactively — the migration already ran. So you test the migration before anyone has the data.


The decision guide

When you sit down to build this, here’s the question stack I’d work through:

  1. Does the core product need offline/local operation? If yes, you’re local-first; IndexedDB (or its native equivalent) is your foundation.

  2. What’s your users’ threat model? “Don’t let a database dump expose my data” → server-managed encryption. “Don’t let the provider read it at all” → end-to-end, and accept the passphrase tax.

  3. How much friction can the core experience absorb? If the answer is “almost none,” the encryption choice is already made for you.

  4. What does “available” mean? Same device, or any device? The latter requires the encrypted cloud path.

And always: lead with the core solution. Privacy is what keeps the door open for the cautious user. The product is what makes them stay.


The takeaway

You can have privacy, integrity, and availability — just not all at 100%. The engineering is in choosing the tradeoff deliberately:

  1. Start local-first — IndexedDB for speed, offline, and privacy-by-default.

  2. Encrypt the cloud copy — at-rest encryption as the pragmatic default; end-to-end if your users demand it.

  3. Defend integrity in layers — hashing, schema validation, referential integrity, concurrency, write guards.

  4. Converge with cheap primitives — debounce, hash-diff, ETag, tombstones, bounded retries.

  5. Migrate idempotently — versioned, background, non-destructive, every app open.

  6. Test the failure paths — compatibility, conflicts, deletions, migrations — so errors stay rare.

None of it requires a proprietary stack or a cloud provider’s opinionated pipeline. It’s hash functions, version numbers, and discipline. And it’s the difference between an assistant users trust with their most personal data — and one they quietly abandon for the one that “just works everywhere.”