Most "desktop apps" built from a web stack are a browser tab wearing a costume — the UI runs locally, but the moment real data is involved it phones home to a server we'd have to run, patch, and pay for. On a desktop network-analytics app we built, we made a different call: no backend at all. A user drops in their own official data export — the archive a platform lets you download about yourself — and everything after that, parsing, storage, and even the LLM-assisted analysis, happens on their machine. The only network calls the app makes are the ones the user explicitly opts into, one HTTPS request per LLM call, using a key they own.
That constraint shaped three separate engineering problems: shipping one binary that runs natively on both Intel and Apple Silicon Macs while bundling a compiled native module; treating a local SQLite file as the entire backend instead of a cache in front of one; and calling an LLM provider directly from a desktop process with the same rigor you'd expect from a server-side integration. None of these are exotic in isolation. Getting all three right in the same app, with nothing running that isn't on the user's disk, is the part worth writing up.
§ 01Why no backend was the actual feature
The product is a relationship-graph and messaging analyzer: it reads a user's own exported connections and message history, computes the numbers a server-side tool would compute (who you talk to, how often, which threads went cold), and lets an LLM summarize patterns on request. Every one of those inputs is data the user already has full legal ownership of and full access to — we're not collecting anything, we're helping them read something they already downloaded. A server sitting between the user and their own file adds attack surface, a retention policy someone has to write, and a monthly bill, for zero product value. Local-first here wasn't a philosophical stance so much as the cheapest architecture that also happened to be the most defensible one on privacy.
The seven local-first ideals — fast, works offline, syncs across devices, survives the vendor going away, private by construction, and user-controlled — line up almost exactly with what "just a desktop app with a local database" gives you for free. We didn't need multi-device sync or CRDTs for this one; a single-user file on a single machine covers the actual requirement. What we did need was the discipline to keep it that way as features got added — the temptation to bolt on "just one" server endpoint for something convenient is constant, and every one we didn't add was a decision, not an accident.
§ 02Shipping one binary for two CPU architectures
Electron Forge's osxUniversal packager option merges the x64 and arm64 builds into a single fat binary, so a Mac from either era launches the app natively instead of translating it through Rosetta. That part is nearly free — a config flag. What it doesn't handle for you is the one dependency that isn't pure JavaScript: better-sqlite3 compiles to a native .node binding, and a native binding compiled for one architecture will not load on the other. Ship the wrong slice and half your users get a silent crash the first time the app touches the database.
// forge.config.ts — universal packaging, native module untouched by webpack const config: ForgeConfig = { packagerConfig: { asar: true, osxUniversal: {}, }, makers: [new MakerDMG({}), new MakerZIP({}, ["darwin"])], plugins: [new WebpackPlugin({ mainConfig, renderer: { /* ... */ } })], };
The second half of the fix lives in the webpack config, not the packager: better-sqlite3 is marked as an externals entry so webpack never tries to bundle it into a single JS file. A compiled binding can't survive being run through a JS bundler — the require call stays a plain require("better-sqlite3"), resolved at runtime against the copy Electron's packager places (and, critically, unpacks from the asar archive) next to the app, one native build per architecture, stitched together by osxUniversal into the same universal .dmg. Get the externals list wrong and the build succeeds, then fails on first launch on exactly one kind of Mac — the failure mode that never shows up on the machine you're developing on.
A native module is the one dependency a bundler can't help you with — it has to survive packaging unbundled, on purpose.
— on shipping better-sqlite3 through Electron Forge
§ 03SQLite as the whole backend, not a cache in front of one
The database is a single file in the app's user-data directory, opened in WAL mode on first launch and never touched by anything outside the process. WAL matters more here than it would on a server: the renderer process is reading (rendering charts, running search) while the main process is writing (import jobs, LLM analysis results), and without write-ahead logging those two would contend for the same lock constantly. With it, readers don't block writers and the app stays responsive mid-import instead of freezing the UI thread on a large file.
- Schema is created idempotently on startup with
CREATE TABLE IF NOT EXISTS, and small forward-only migrations run behind aPRAGMA table_infocheck — no migration framework, no separate migration step for the user to run. - Foreign keys are on (
PRAGMA foreign_keys = ON), so an accounts / uploads / contacts / messages hierarchy stays consistent without application-level cleanup code. - Every query is synchronous.
better-sqlite3doesn't offer an async API, and for a local file that's a feature, not a limitation — there's no event loop juggling, no connection pool, no query queue to reason about.
Treating SQLite as the actual backend, not a local mirror of a "real" database somewhere else, changes the questions you ask. There's no schema migration to coordinate across environments, no connection string to secure, no ORM abstracting over a network round-trip that doesn't exist. The file itself is the backup unit — copy it, and you've copied the whole app's state.
§ 04Importing a user's own export, defensively
Data exports are not clean CSVs. Real ones arrive as a zip that sometimes contains a second zip nested one level in, CSV files with a few lines of preamble text above the actual header row, and rows that repeat across re-exports because the platform regenerated the same archive on a later date. The import path has to assume all three, every time, because it only gets one shot at a user's trust if it silently drops or duplicates their own data.
// unwrap a nested zip, then strip preamble lines before the real header async function unwrapNestedZip(zip: JSZip): Promise<JSZip> { const entries = getTopLevelFiles(zip); if (entries.length === 1 && entries[0].path.endsWith(".zip")) { return JSZip.loadAsync(await entries[0].file.async("arraybuffer")); } return zip; } function stripCsvPreamble(csv: string): string { const lines = csv.split("\n"); const headerAt = lines.findIndex(l => l.startsWith("First Name,")); return headerAt > -1 ? lines.slice(headerAt).join("\n") : csv; }
Deduplication is handled where it's cheapest to get right: at the schema layer, not in application logic. A UNIQUE(account_id, profile_url) constraint on contacts and a compound unique key on messages mean a re-imported archive is a no-op on rows that already exist, via INSERT OR IGNORE, instead of a hand-rolled diff. That's the same instinct as the WAL choice in §03 — push correctness into SQLite's own guarantees wherever it'll take the weight, rather than re-implementing them in TypeScript above it.
§ 05Calling an LLM straight from the client
The one deliberate exception to "nothing leaves the machine" is the LLM call, and it's opt-in, per-request, and made with a key the user supplies and stores locally — never a key we hold or a request that routes through infrastructure of ours. From the desktop process, that call gets the same treatment we'd give it on a server, because a slow or flaky provider is just as capable of hanging a desktop app's main process as it is a backend request handler:
- Every request goes through
fetchwrapped in anAbortControllerwith a hard timeout, so a provider that stalls doesn't hang the analysis run indefinitely. - A 429 response triggers a single delayed retry rather than an immediate failure — cheap insurance against the rate limit windows every provider enforces differently.
- Token counts come back on every response and get written to the same SQLite row as the result, so cost is auditable per analysis, not estimated after the fact.
- Text is passed through a sanitizer that replaces lone UTF-16 surrogates before it ever reaches a request body — one unpaired surrogate from a copy-pasted message and a provider's JSON parser rejects the entire payload.
The desktop process making the call is the same client, and the failure modes are exactly the ones you'd defend against on a server: timeouts, rate limits, malformed input.
We kept the adapter shape provider-agnostic on purpose — the same pattern we use in server-side work, detailed in this write-up on provider abstraction — so a user who prefers a different vendor's key isn't blocked by an integration we hardcoded to one API shape. The provider changes; the timeout, retry, and sanitization logic around it doesn't.
§ 06What "nothing leaves the machine" actually buys you
None of the three pieces here — universal packaging, SQLite as backend, direct-from-client LLM calls — is individually hard. What's hard is resisting the gravitational pull toward a server the moment any one of them gets slightly inconvenient: an easier CI pipeline if you skip universal builds and ship Intel-only, a familiar ORM if you put a thin API in front of the database, a hosted proxy if you don't want to handle 429s in a desktop process. Each of those shortcuts quietly reintroduces the thing the architecture was built to avoid — a server holding a copy of data that was never meant to leave a laptop.
The payoff is a smaller one than "local-first" evangelism usually promises, and a more concrete one: no backend to keep patched, no data-retention question to answer, and an app a user can trust with their own export precisely because there's nowhere else for it to go except the analysis they asked for.
— End of essay. Want a native app that keeps your users' data on their own machine? Start a project →
