← Back to blog
Development & TechJune 16, 202616 min read

Local-First Software Is the Future — Here's How I Built One With Tauri, SQLite, and CRDTs

Cloud-First Is a Trap

Most apps today assume the internet is always there. Open your note-taking tool, your project manager, your code editor's extension — if the network drops, you're staring at a spinner or a blank screen. Your data lives on someone else's server. You're renting access to your own information.

I've spent the last decade running performance marketing campaigns where a dropped connection mid-budget-adjustment could mean thousands of dollars wasted. And I've spent the last few years building developer tools where "please wait, connecting to server" is an unacceptable UX. That frustration led me to build OpsConsole — a local-first desktop app for developers who live in the terminal. Real PowerShell and CMD sessions via native PTY, saved command library with autocomplete, 100% offline, zero telemetry. Built with Tauri + React + Rust.

Local-first isn't a niche philosophy. It's an architectural decision that makes your app faster, more private, and more resilient. The modern stack — Tauri for the shell, SQLite for local persistence, CRDTs for sync — has matured enough that shipping a local-first app is practical, not experimental.

Here's what I've learned building one, and why I think this model beats cloud-first for most desktop and internal-tool use cases.

What Local-First Actually Means

Martin Kleppmann's foundational paper on local-first software frames it clearly: you own your data, in spite of the cloud. The user's device is the primary source of truth. The cloud is an optional sync layer — nice to have, not required to function.

This is fundamentally different from "offline mode" in a cloud app. Offline mode is a degraded fallback — the app caches some data, you get limited functionality, and when connectivity returns everything syncs back up. Local-first inverts that: the app works fully without network; the network only enhances features like cross-device sync or collaboration.

The practical test is simple: pull the ethernet cable. Does your app still work? If the answer is "mostly, but..." — that's offline mode. If the answer is "yes, fully, and I didn't even notice" — that's local-first.

Three Pillars: Privacy, Speed, Resilience

  • Privacy by default. Data lives on the user's machine. You can't leak what you don't store. No database to breach, no server logs to subpoena, no third-party analytics hoovering up keystrokes. This isn't privacy as a feature — it's privacy as an architectural consequence.
  • Instant performance. Every interaction hits a local database, not an API endpoint. Reads are microseconds, not milliseconds. Writes don't wait for HTTP round-trips. The UI feels native because it is native.
  • Genuine offline capability. Airplanes, dead zones, server outages — your app keeps working. For developer tools, this isn't optional. I don't want my terminal workspace telling me it can't load my saved commands because the auth server is down.

The Stack: Tauri + SQLite + CRDTs

Here's the architecture I've landed on after building OpsConsole and evaluating the landscape:

  • Tauri — Rust-based desktop shell. Replaces Electron with something lighter, faster, and more secure.
  • SQLite — Local database. Battle-tested, zero-config, single-file. The most deployed database engine in the world for a reason.
  • CRDTs — Conflict-free replicated data types. The sync layer that makes multi-device consistency possible without central coordination.

Each piece solves a specific problem. Let's break them down.

Why Tauri, Not Electron

I've built Electron apps. They work. They also ship a full Chromium instance, weigh 150MB+ for a hello-world app, and consume RAM like it's free. For a local-first tool that should feel lightweight and stay out of your way, Electron is the wrong default.

Tauri uses the system's native WebView instead of bundling Chromium. The result:

  • Bundle size: OpsConsole ships at roughly 8MB vs. 150MB+ for an equivalent Electron app. That's not a rounding error — it's a different category of software.
  • Memory footprint: Tauri apps typically use 30-50MB of RAM at idle. Electron apps start at 150MB and climb from there.
  • Security: Tauri's permission model is granular — you explicitly allow filesystem access, shell commands, and IPC calls. Electron's model is more permissive by default.
  • Filesystem access: Critical for local-first. Tauri's Rust backend talks directly to the filesystem and SQLite. No sandboxed IPC bottleneck like Electron's renderer process.

The IPC Architecture Matters

Here's a detail most tutorials skip: in Tauri, database access goes through Rust commands via IPC, not directly from the WebView. Your frontend calls an invoke, Rust handles the query, returns the result. This sounds like overhead, but in practice it's negligible — we're talking microseconds for local operations.

A basic Tauri command for reading from SQLite looks like this:

#[tauri::command]
fn get_commands(db: State<Mutex<Connection>>) -> Result<Vec<SavedCommand>, String> {
    let db = db.lock().map_err(|e| e.to_string())?;
    let mut stmt = db
        .prepare("SELECT id, name, command, tags FROM commands ORDER BY updated_at DESC")
        .map_err(|e| e.to_string())?;
    let commands = stmt
        .query_map([], |row| {
            Ok(SavedCommand {
                id: row.get(0)?,
                name: row.get(1)?,
                command: row.get(2)?,
                tags: row.get(3)?,
            })
        })
        .map_err(|e| e.to_string())?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| e.to_string())?;
    Ok(commands)
}

And the frontend call:

const commands = await invoke<SavedCommand[]>("get_commands");

That's it. No API client, no auth headers, no loading states for network requests. The data is right there.

SQLite: The Unsung Hero of Local-First

SQLite doesn't get enough credit. It's the most deployed database engine on the planet — literally billions of instances running on every smartphone, every browser, every embedded device. And for local-first desktop apps, it's the obvious choice.

Why not something fancier? IndexedDB? LevelDB? A custom format?

  • SQL is a known quantity. Every developer can write a query. No new query language to learn, no ORM lock-in required.
  • Single-file database. Copy the file, back it up, version it with git. Try that with a Postgres instance.
  • ACID transactions. Your data doesn't corrupt on crash. This matters more than you think when users are force-quitting apps and pulling power cords.
  • Performance. SQLite reads are effectively instant for local-first datasets. We're talking sub-millisecond for typical queries.

The Encryption Gap

Here's an honest limitation: the official @tauri-apps/plugin-sql doesn't support encryption out of the box. If you need encrypted local storage — say, for a note-taking app handling sensitive data — you have options but they require more work:

  • SQLCipher — encrypted SQLite build. Requires compiling from source or finding a compatible build for each target platform.
  • tauri-plugin-libsql — a community plugin by huakun that wraps libsql (a SQLite fork by Turso) with encryption support and better Drizzle ORM integration. It's newer but actively maintained.
  • Application-level encryption — encrypt sensitive fields before writing to SQLite. More work, but doesn't require custom SQLite builds.

For OpsConsole, the data isn't sensitive (saved terminal commands and session configs), so plain SQLite is fine. But if you're building a local-first app for notes, passwords, or health data, plan for encryption from day one. Retrofitting it is painful.

CRDTs: When They're Worth It (And When They're Overkill)

This is where I need to be transparent about the boundary between my direct experience and my evaluation of options.

What I've done: OpsConsole is currently single-device. No sync. The local SQLite database is the entire source of truth, and that's the right call for a terminal workspace — you don't typically need your command library synced across machines. The app works fully offline because there's no sync layer to break.

What I've evaluated but not shipped: Multi-device sync using CRDTs. I've researched Yjs, Automerge, and the emerging sync engines (PowerSync, Electric SQL, libsql/Turso's sync protocol) for other projects. Here's my honest assessment.

What CRDTs Actually Solve

CRDTs — Conflict-free Replicated Data Types — are data structures that can be updated independently on different devices and then merged without conflicts. No central server arbitrating who wins. No "last write wins" data loss. Each device has a full replica and can merge changes from any other device in any order.

This sounds abstract. The practical translation: two people edit the same document offline, and when they reconnect, both sets of changes survive. Not by overwriting — by merging.

The canonical use cases where CRDTs shine:

  • Collaborative text editing — Yjs and Automerge both handle this well. Multiple cursors, concurrent edits, no conflicts.
  • Shared lists and maps — Add/remove items from any device, merge later.
  • Counter and register types — Simple values that need to converge across replicas.

When CRDTs Are Overkill

Not every app needs CRDTs. If your data model is simple and your sync requirements are modest, CRDTs add complexity that isn't justified. A few honest trade-offs:

  • Debugging CRDT merge issues is genuinely hard. When something goes wrong in a merge, the state is distributed across devices. You can't just query a central database to see "what happened." This is not a solved problem in developer experience.
  • CRDTs have storage overhead. They typically maintain tombstones (deleted items) and vector clocks (metadata for ordering). Your database grows in ways a simple SQLite table doesn't.
  • The learning curve is real. CRDTs require thinking about data differently — not as "current state" but as "accumulated operations." If your team hasn't worked with them before, budget significant ramp-up time.
  • Many apps just need simple sync. If you're syncing configuration data, bookmarks, or command libraries — where conflicts are rare and "last write wins" is acceptable — a simple timestamp-based sync with a cloud database (Turso, Supabase, even a JSON file on S3) is far simpler and more debuggable.

My rule of thumb: if you're building Google Docs, you need CRDTs. If you're building a todo app, you probably don't.

Sync Engine Options Worth Knowing

If you do need sync, the landscape is evolving fast:

  • Yjs — Mature, well-documented, great for collaborative text. Has a rich ecosystem of providers for different backends.
  • Automerge — More general-purpose, strong academic backing (including from Kleppmann's group). The v2 rewrite improved performance significantly.
  • PowerSync — Sync engine that sits between SQLite and Supabase/Postgres. Handles the sync layer so you don't implement CRDTs yourself. Worth evaluating if you want local-first with a Postgres backend.
  • Electric SQL — Similar positioning to PowerSync — syncs Postgres to local SQLite. Newer, ambitious, but still maturing.
  • libsql/Turso — SQLite fork with built-in sync to Turso's cloud. The tauri-plugin-libsql makes this relatively straightforward for Tauri apps. Good option if you want simple cloud sync without full CRDT complexity.

I haven't shipped production sync with any of these yet. If you have, I'd genuinely like to hear about your experience — the space needs more first-person accounts.

What I Learned Shipping OpsConsole

Building a local-first app with Tauri taught me things that reading documentation didn't. A few specifics:

Local-First Architecture Beats Cloud-First for Developer Tools

OpsConsole's core value proposition is that it always works. Open it, run commands, save your library. No login screen. No "connecting to server..." No auth tokens expiring mid-session. Developers reach for terminal tools because they're fast and reliable. A cloud-first terminal workspace contradicts its own purpose.

The PTY Integration Is Where Tauri Shines

Running real PowerShell and CMD sessions through a native PTY (pseudo-terminal) requires system-level access that a browser tab simply can't provide. Tauri's Rust backend handles this natively. The WebView is just the UI layer — all the heavy lifting (process spawning, I/O, signal handling) happens in Rust. This separation is clean and performant.

Zero Telemetry Is a Feature, Not an Oversight

I made a deliberate choice: OpsConsole collects nothing. No usage data, no crash reports, no "anonymous" analytics. This isn't idealism — it's product strategy. Developer tools that phone home erode trust. Local-first makes this easy because there's no server to phone home to in the first place.

What Went Wrong

Honest accounting: the Tauri plugin ecosystem is smaller than Electron's. When I needed specific functionality (like advanced filesystem watching), I ended up writing custom Rust code rather than pulling in a plugin. The documentation for Tauri v2 is improving but still has gaps — I spent more time than I'd like digging through source code and GitHub issues. And cross-platform testing (especially Windows vs. macOS PTY behavior) was more work than expected.

When NOT to Go Local-First

I'm not going to pretend local-first is always the right call. It's not. Here are the cases where cloud-first still makes more sense:

  • Real-time multi-user collaboration is the core feature. Google Docs, Figma, multiplayer games — these need a central coordination layer. You can add local-first resilience, but the cloud is the source of truth.
  • Your data is too large for local storage. Video editing, large dataset analysis, scientific computing — if the data doesn't fit on the device, local-first doesn't work.
  • You need a single source of truth for compliance. Some regulated industries require centralized audit logs and access controls that local-first architectures make harder.
  • Your users are non-technical and expect seamless sync. Local-first apps put more responsibility on the user for backups and data management. For some audiences, "it just syncs" is worth the trade-offs.

But for developer tools, internal dashboards, note-taking apps, project management for small teams, and basically any app where the user creates and owns their data — local-first is the better default. You should have to justify going cloud-first, not the other way around.

Getting Started: Your First Local-First Tauri App

If this resonates, here's a practical path to start:

  1. Scaffold a Tauri v2 app. npm create tauri-app@latest. Pick React (or your preferred framework). You'll have a working desktop app in under a minute.
  2. Add SQLite. Use @tauri-apps/plugin-sql for simple cases, or tauri-plugin-libsql if you need encryption or plan to add Turso sync later.
  3. Write your data layer in Rust. All database operations go through Tauri commands. Keep your frontend thin — it renders state, it doesn't manage it.
  4. Test offline first. Literally disconnect your network before testing. If anything breaks, you've found a cloud dependency to remove.
  5. Add sync later if needed. Start without it. Ship the local-only version. Let user feedback tell you whether sync is actually needed and what kind. Then evaluate CRDTs vs. simple sync vs. Turso based on real requirements, not hypotheticals.

The key insight: local-first is easier to start with and add sync to later than the reverse. Retrofitting offline capability onto a cloud-first app is painful. Building on a local foundation and adding optional cloud features is natural.

The Bottom Line

Cloud-first architecture made sense when local devices were weak and storage was expensive. That's no longer true. Your laptop has 16GB of RAM and a terabyte of SSD. It can run SQLite queries faster than any cloud API will ever respond. And yet most apps still treat the local machine as a thin client.

Local-first flips that. The device is the primary source of truth. The cloud is optional. Your app is faster, more private, and more resilient as a direct consequence of the architecture — not because you bolted on features.

I built OpsConsole this way because I couldn't find a terminal workspace that respected those principles. The stack — Tauri for the shell, SQLite for storage, and eventually CRDTs for sync when the need arises — is mature enough to ship real software today.

If you're building a desktop app or internal tool, start local-first. You can always add cloud features later. You can't easily add local-first to a cloud-first architecture.

Star OpsConsole on GitHub to see local-first architecture in action — or start building your own local-first app with Tauri today.

Frequently Asked Questions

  • What does local-first actually mean in practice?

    Local-first means the user's device is the primary source of truth for all data. The app works fully without an internet connection. The cloud is an optional layer for features like sync or collaboration — it enhances the app but isn't required for it to function. The practical test: if you disconnect from the internet and the app still works completely, it's local-first.

  • How is local-first different from offline mode in a cloud app?

    Offline mode is a degraded fallback — the app caches some data, offers limited functionality, and syncs when connectivity returns. Local-first inverts this: the app works fully by default, and the network only adds optional features like cross-device sync. Offline mode says 'we'll try to keep working without the cloud.' Local-first says 'the cloud is nice but irrelevant to core functionality.'

  • Why use Tauri instead of Electron for a local-first app?

    Tauri ships smaller bundles (~8MB vs. 150MB+), uses less memory (30-50MB vs. 150MB+ at idle), and provides a more granular security model. For local-first apps specifically, Tauri's Rust backend gives direct filesystem and database access without the IPC bottleneck of Electron's renderer sandbox. The native WebView approach also means your app respects the user's system rather than bundling a full Chromium instance.

  • How do CRDTs work and when do you actually need them?

    CRDTs (Conflict-free Replicated Data Types) are data structures that can be updated independently on different devices and merged without conflicts. They're essential when you need concurrent multi-user editing where both sets of changes must survive (like collaborative text editing). You probably don't need them if your sync requirements are simple — like syncing bookmarks or settings where 'last write wins' is acceptable. CRDTs add significant complexity in debugging, storage overhead, and learning curve.

  • Can you sync local-first data across devices without a central server?

    Yes, using peer-to-peer sync with CRDTs — devices can sync directly via WebRTC, Bluetooth, or local network. However, most practical implementations use a lightweight server as a relay and persistence layer (not as the source of truth). Tools like Yjs have providers for this, and Turso's sync protocol offers a simpler alternative that doesn't require full CRDT implementation. Pure P2P sync works but adds complexity around device discovery and availability.

  • What are the trade-offs of local-first architecture?

    The main trade-offs: (1) Users bear more responsibility for backups — if their device dies and there's no sync, the data is gone. (2) Multi-device sync adds significant complexity, especially with CRDTs. (3) The Tauri plugin ecosystem is smaller than Electron's, so you may write more custom Rust code. (4) Cross-platform testing for system-level features (like PTY access) requires more effort. (5) Some use cases — real-time collaboration on large datasets, compliance-heavy audit requirements — genuinely need a central source of truth.

  • Is SQLite enough, or do you need something like libsql/Turso for sync?

    SQLite is enough if your app is single-device or if you implement your own sync layer. If you want built-in cloud sync without writing it yourself, libsql (a SQLite fork by Turso) adds a sync protocol that can replicate your local database to Turso's cloud. The tauri-plugin-libsql community plugin makes this accessible in Tauri apps. For most local-first apps, start with plain SQLite and add Turso sync only when users actually need multi-device access.

References

#SQLite#local-first software#offline-first architecture#privacy-first software#CRDTs#Tauri framework#Rust desktop apps#Tauri vs Electron

Comments (0)

Loading comments...