Ruvy Studio: Building a Native PostgreSQL Client with Tauri and Rust

October 6, 2025

RustTauriPostgreSQLReactSQLx

I wanted a local SQL client that didn't feel like a browser tab pretending to be a desktop app. The main frustration with Electron-based clients is that rendering large result sets means holding everything in the DOM—scroll to row 50,000 and the tab starts lagging. I also wanted query execution to be async by default, not something I had to opt into per query.

That's what Ruvy Studio is: a Tauri app where the database driver lives entirely in Rust, and the React frontend only ever sees serialized row batches over IPC.


Architecture

Tauri gives you a WebView for the UI and a Rust binary as the "core" process. The split I settled on: all network I/O, connection pooling, and type serialization happens in Rust. The frontend is responsible only for rendering what it receives.

graph TD
    UI["React: SQL Editor"] -- "tauri::invoke('run_query')" --> IPC[Tauri IPC Bridge]
    IPC --> Handler[Rust: Command Handler]
    Handler -- "tokio::spawn" --> Worker[Async SQLx Task]
    Worker -- "SQLx RowStream" --> DB[(PostgreSQL)]
    DB --> Worker
    Worker -- "app.emit('query_row_batch')" --> UI
    UI --> Grid[Glide Data Grid]

The query lifecycle is: frontend calls invoke, Rust spawns a Tokio task that opens a SQLx stream against the pool, collects rows into batches, and emits events back to the UI. The frontend listens for query_row_batch events and appends incoming rows to a Zustand store. The Data Grid renders only what's in the viewport.


IPC Streaming and Micro-Batching

The first thing I ran into was that emitting one IPC event per row was too slow—the overhead of serializing and dispatching each event added up quickly. Sending all rows at once was the opposite problem: the Rust task would block until it had collected every row before emitting anything, which made the UI feel frozen for large queries.

I landed on micro-batching: the Rust side collects rows up to a fixed chunk size before emitting. The frontend starts receiving and rendering data almost immediately, and the message bus doesn't get saturated. The chunk size was something I had to tune—too small and you're back to high event overhead, too large and you reintroduce the latency problem.


Query Cancellation

Canceling a running query turned out to be more involved than I expected. Just dropping the Rust task isn't enough—the PostgreSQL backend process keeps running on the server until it finishes or times out.

I store the pg_backend_pid() for every active query in a HashMap keyed by a query ID. When a cancel comes in from the UI, I spawn a separate Tokio task that opens a second connection and runs SELECT pg_cancel_backend(pid). At the same time, I drop the local row stream receiver, which causes the SQLx stream to be abandoned on the next poll. The two operations happen concurrently—server-side cancellation and local task cleanup.

The pg_cancel_backend approach requires that the cancellation connection has sufficient privileges. I ran into a permission issue early on when testing against a restricted user—the function silently returns false rather than throwing an error, which made it look like cancellation was working when it wasn't.


Type Serialization

SQLx's AnyRow API gives you column values as sqlx::Value instances, which need to be matched against Postgres type names before serialization. I built a recursive match over TypeInfo::name() to convert Postgres-specific types into JSON-safe representations before they cross the IPC boundary.

The types that caused the most friction: numeric (arbitrary precision—can't map cleanly to a JS float), timestamptz (needs timezone normalization), and array types like int4[] (need to be serialized element by element). Anything I didn't recognize I serialized as a string rather than letting it silently become null in the frontend.


Data Grid and Virtualization

Glide Data Grid renders to a <canvas> element, which means it bypasses the DOM entirely for cell rendering. The practical effect is that I can hold 100,000 rows in a Zustand store and the grid only ever paints the cells currently in the viewport. Scrolling stays responsive regardless of how many rows the query returned.

The tradeoff is that canvas-based grids are harder to customize. I spent more time than expected getting cell selection, column resizing, and copy behavior working correctly—things that come free with a table-based approach.


Connection Pooling

I use a global RwLock<HashMap<String, Pool<Postgres>>> to store SQLx connection pools, keyed by a connection ID. Opening a new pool has a noticeable handshake cost, so re-using the same pool across queries in the same tab avoids that overhead on subsequent runs. Each tab gets its own connection ID, so closing a tab also drops its pool entry.

lazy_static felt like the right tool here—the pool map is initialized once, lives for the process lifetime, and is accessed from multiple Tokio tasks. The RwLock allows concurrent reads (multiple queries in parallel tabs) while serializing pool insertions.


Lessons Learned

  • IPC serialization is the real bottleneck, not query execution: I spent time optimizing the SQLx query path before realizing the bottleneck was Serde serializing large rows. Getting the type mapping right and keeping serialized payloads small mattered more.
  • Rust's type system caught things early: Several type mismatches between what SQLx returned and what I expected would have been runtime surprises in a Node.js equivalent. The compiler caught them at build time.
  • pg_cancel_backend needs validation: It returns a bool, not an error. I had to explicitly check the return value and surface it to the UI, otherwise cancellation silently failed against restricted users.

What I'd Add Next

SSH tunnel support inside the Rust core—right now connections require direct network access, which rules out connecting to private RDS instances without external tooling. I'd also add a streaming CSV export that writes directly to disk row-by-row, so large result sets can be exported without holding everything in memory.