A database client has a fundamental problem: it must display results that can range from 10 rows to 10 million. Loading an entire result into memory before displaying it is the simplest solution, and it is exactly what many tools do. It works until the day a developer runs a SELECT * on a production table without a WHERE clause.
In QoreDB, results are never fully accumulated on the backend side. Each row is transmitted to the frontend as soon as it is available, through a streaming pipeline that keeps memory consumption constant regardless of the dataset's size.
A bounded channel between the driver and the frontend
The central mechanism relies on a tokio channel bounded to 100 elements. On the driver side, each row read from the database cursor is sent into this channel as a typed event (StreamEvent). On the consumer side, the events are read and passed to the frontend via Tauri IPC.
The bounded channel naturally introduces backpressure: when the consumer doesn't read fast enough, the channel fills up and the driver pauses. There is no unbounded buffering, no silent accumulation. If the frontend slows down, the driver slows down too. This mechanism is the same for all supported engines.
The streaming protocol is structured around four event types: Columns (sent only once at the start with the column metadata), Row (a row of data), Error (in case of a problem during the traversal), and Done (end of the stream with the total row count). This separation lets the frontend start rendering as soon as the first row is received, without waiting for the query to finish.
Native cursors per driver
Each driver implements streaming using the native cursors of its engine. PostgreSQL, MySQL/MariaDB, SQLite, DuckDB, and SQL Server all expose an execute_stream method on the DataEngine trait. The PostgreSQL implementation, for example, uses SQLx's fetch() method, which returns an asynchronous Rust Stream. Each row is converted and sent into the channel as it goes.
This choice to delegate to native cursors is important. QoreDB doesn't load N rows into a Vec to retransmit them afterward. The traversal is incremental: one row enters the channel, one row leaves it. The memory used by the streaming pipeline is proportional to the size of the channel (100 elements), not to the size of the result.
The DataEngine trait also declares a supports_streaming() method that lets the frontend know whether the active driver supports this mode. When streaming is not available, execution falls back to the classic path with a complete result in memory, but bounded by pagination.
Export as a critical use case
Streaming comes into its own during data export. Exporting a table of several million rows to CSV, JSON, or Parquet is a common operation. Without streaming, you would have to load everything into memory before writing the file. With QoreDB's export pipeline, rows are written to the output file as they arrive.
The export pipeline uses the same channel bounded to 100 elements. A configurable batch_size (1000 rows by default) controls how often the writer flushes. The user can also set a row limit, in which case the driver is cancelled cleanly once the limit is reached. All of it runs in a dedicated Tokio task, without blocking the interface.
Progress is emitted to the frontend at most every 250 milliseconds, to avoid saturating the Tauri IPC channel with overly frequent updates. This throttling is deliberate: on an export of 5 million rows, emitting one event per row would overload the webview.
On the frontend side: incremental loading and virtualization
The frontend rounds out the setup with a useInfiniteTableData hook that loads data in chunks of 100 rows. When the user scrolls toward the bottom of the grid, the next chunk is requested from the backend via a paginated query (OFFSET/LIMIT delegated to the engine). The rows accumulate in the React state and the grid displays them through DOM virtualization, which means only the rows visible on screen are actually rendered.
Pagination on the backend side is also bounded. The page_size is capped at 10,000 rows maximum per query, via an explicit clamp in the code. This is not an arbitrary choice: beyond that threshold, JSON serialization for the IPC transit and frontend rendering become bottlenecks on a desktop application.
In practice: what the user sees
For the user, all of this is transparent. When they run a query, the first rows appear almost instantly. As they scroll, new rows load on demand. When they start an export, a progress bar appears with the number of rows processed, and they can cancel the operation at any time.
Streaming also enables clean cancellation. If the user clicks "Stop" during a long query, the cancellation token is propagated all the way to the driver, which interrupts the cursor traversal. On PostgreSQL, this translates into a call to pg_cancel_backend. On MySQL, into a KILL QUERY. The bounded channel guarantees that memory is freed quickly once the cancellation takes effect.
Result streaming in QoreDB is not a cosmetic optimization. It is an architectural constraint that runs through every layer, from the engine's native cursor to the frontend's DOM virtualization. The result is a client that can handle large datasets without memory consumption becoming proportional to their size.
Stay updated on new releases
Subscribe to get product releases, new drivers notifications, and technical tutorials.

