Async I/O with Embedded SQLite
cratestack-rusqlite is intentionally synchronous — its ModelDelegate API does not return futures, the runtime holds a Mutex<Connection>, and SQL calls block the calling thread. That’s the right shape for mobile and desktop apps, where the UI layer drives a Rust core over FFI and there’s no async runtime in the picture.
It becomes a question the moment you wrap the embedded backend in an axum server, a notify-driven daemon, or any other tokio-shaped process: how do you call sync persistence code from an async handler without stalling the tokio worker pool?
This guide is the answer.
When this applies
You need the pattern in this guide if all three of the following are true:- You’re using
cratestack-rusqlite(i.e.include_embedded_schema!) rather thaninclude_server_schema!+ Postgres. - Your host process is async — a
#[tokio::main]binary, anaxum-served HTTP server, a long-running daemon driven bytokio::select!, etc. - You’re using tokio’s multi-threaded runtime (the default for
#[tokio::main]). On the current-thread runtime the seam still exists but the cost of getting it wrong is smaller.
What can go wrong
The naive shape is to call the delegate directly from an async handler:notes.create(...).run()?, the worker is pinned inside that synchronous call for as long as SQLite takes to acquire the mutex, write the row, optionally fsync, and update the WAL. None of the other tasks scheduled on that worker make progress in the meantime.
For WAL-mode SQLite on NVMe with no contention this is sub-millisecond and probably fine. As soon as you add a second writer, a slower disk, or an fsync-per-write configuration, latencies stretch into the tens of milliseconds — and any other request that landed on the same worker is silently delayed for the same window.
There’s also a correctness hazard: tokio’s documentation explicitly warns that blocking a worker for “longer than 10–100 microseconds” can deadlock the scheduler when combined with other blocking behavior.
The spawn_blocking pattern
The fix is one call. Move the synchronous work onto tokio’s dedicated blocking pool with tokio::task::spawn_blocking:
spawn_blocking returns its JoinHandle — typically in microseconds.
Three things to notice:
Arc<RusqliteRuntime>is the carrier.RusqliteRuntimeis notCloneby design (the underlyingMutex<Connection>shouldn’t be silently duplicated). Wrap it in anArconce at startup and clone theArcper call.- Two
?operators.spawn_blocking(...).awaitreturnsResult<Result<T, RusqliteError>, JoinError>. The first?propagates panics from the blocking task asJoinError; the second propagates the underlyingRusqliteError. You can collapse these with a helper if you’d rather, but the shape is informative. - The closure is
Send + 'static. Anything captured by the closure crosses thread boundaries, so request-derived state (input, the clonedArc) needs to satisfySend.RusqliteRuntimealready does — it’sSend + Sync.
Conceptually
spawn_blocking is “promote synchronous code to async-friendly by paying a thread-pool hop.” It’s the same pattern you’d use to call a blocking C library from a tokio handler, or to read a stdlib File outside tokio::fs. There is nothing CrateStack-specific about it — it just happens to be the seam embedded users hit first.Sharing the runtime
Open the database once at startup, wrap it inArc, and clone the Arc everywhere you need it:
Arc<RusqliteRuntime> is the handle that gets passed around. Mutex<Connection> inside the runtime serializes actual SQL access. SQLite in WAL mode allows concurrent readers with at most one writer — but the in-process mutex enforces single-writer at the runtime layer, which is the simplest correct default. If you need a connection pool, open multiple RusqliteRuntime instances against the same file (each carries its own connection) and arbitrate access yourself.
Bridging non-tokio threads
Some libraries (filesystem watchers, OS event hooks, native callback APIs) deliver their events on threads they own — outside tokio’s scheduler. The pattern is to push those events into atokio::sync::mpsc channel and process them from a tokio task that owns the database side.
tokio::sync::mpsc::unbounded_channel is the typical choice: its send is non-async and safe to call from any thread, and recv is async on the consuming end.
notify), GUI callbacks, native code calling back into Rust via extern "C", signal handlers — anywhere events arrive on a thread tokio didn’t create.
Graceful shutdown
axum::serve(...).with_graceful_shutdown(...) waits for in-flight requests to complete before exiting. Pair it with tokio::signal::ctrl_c():
tokio::select! loop) rather than a server, treat shutdown the same way — drain any buffered state, persist via spawn_blocking, then return from main. Dropping the Arc<RusqliteRuntime> triggers SQLite’s normal close path; WAL contents are checkpointed into the main database file as part of that. Do not abort the process while a blocking task is mid-write — SQLite recovers correctly via WAL on next open, but consistency-critical workloads should let the task drain.
When not to bother
spawn_blocking is cheap but it’s not free — every call pays a thread-pool dispatch plus the cost of the channel handoff for the result. Two cases where you can skip it:
#[tokio::main(flavor = "current_thread")]— single-threaded runtimes have only one async worker, and blocking it stalls the only thread you have either way. If you’re committed to that runtime flavor, holding the connection inline on the same thread is no worse than aspawn_blockinghop. (You almost certainly don’t want this for a server, but it’s reasonable for a daemon with one logical task.)- Hot reads that always come from memory. SQLite’s page cache makes repeated reads against the same hot pages effectively in-memory. If profiling shows a read path is sub-microsecond and never touches disk, the
spawn_blockingoverhead can be the dominant cost. Measure before you remove the wrapper.
spawn_blocking everywhere is the safe call. Skip it only where you have profiling data to justify the deviation.
Reference examples
Two examples in the framework repo demonstrate the full pattern end-to-end:examples/embedded-daemon—notifywatcher → tokio mpsc → debouncer state machine →spawn_blockingflush. The “long-running daemon with local SQLite state” shape. Includes a pureDebouncerunit-test boundary (the persistence layer is testable without tokio, the daemon layer requires it).examples/embedded-webhook— axum +include_embedded_schema!, no Postgres. The “small HTTP service with its own SQLite” shape — the inverted twin of theserver_basicexample, which usesinclude_server_schema!+ Postgres.
lib.rs / main.rs split: lib.rs exposes a build_router (webhook) or pure Debouncer + persist_event (daemon) so integration tests can exercise the persistence layer without binding a TCP port or watching a real filesystem. main.rs is the thinnest possible wrapper around tokio::main.
Read next
- Offline-First with Embedded SQLite — the embedded backend without the async wrapper. Read this first if you haven’t.
- Telemetry — wiring
tracingso thespawn_blockingboundary is observable. - Scalars — the canonical TEXT/BLOB encoding that round-trips through SQLite.