Node gives you one thread to run JavaScript on, and everything your server is doing at any given moment, every in-flight HTTP request, every open WebSocket, every timer, shares that thread, so a single synchronous call into a native addon that takes forty milliseconds is not a forty-millisecond problem, it is a forty-millisecond freeze across the whole process. That distinction barely matters when the call is a point read that hits the page cache and comes back in four microseconds, but it matters enormously when the call is a condition query that has to walk runs on disk, or a put that lands right as the memtable decides to spill, because embedded databases do occasionally touch storage and storage occasionally takes real time. The classic answer in the Node ecosystem is libuv’s thread pool, which is how fs and crypto hide their blocking work, and the napi-rs answer is spawn_blocking, which hands the call to a blocking thread and resolves a Promise when it finishes, and Kit’s async surface is built on the second of those, wired straight into the same Rust core the sync API uses.
Which calls actually leave the event loop
The genuinely non-blocking calls are the async variants implemented as native spawn_blocking methods on the addon’s TableHandle, and Kit re-exposes them on the database handle with the table name as the first argument, delegating straight through, so the Rust work runs on a blocking thread while your event loop keeps pumping:
const put = await db.putAsync("events", cells); // Promise<PutResult>
const row = await db.getAsync("events", rowId); // Promise<RowJs | null>
const rows = await db.queryAsync("events", conds); // Promise<RowJs[]>
const total = await db.countAsync("events"); // Promise<bigint>
const n = await db.countWhereAsync("events", conds); // Promise<bigint>
const arrow = await db.queryArrowAsync("events", conds); // Promise<Buffer>, Arrow IPC
The conditions for queryAsync and countWhereAsync are the same ConditionSpec objects the sync query path takes, which means you can build them with the ConditionKind constants or let compilePredicate turn a Kit Predicate into them, and setSimilarityAsync rides on top of queryAsync for its MinHash candidate fetch, so at least the I/O half of the sparse-vector similarity path stays off the loop even though the scoring that follows runs in JavaScript. Because these return ordinary promises, the usual concurrency tricks apply, and firing a count and a query together genuinely overlaps them instead of serializing on the JS thread:
const [rows, total] = await Promise.all([
db.queryAsync("events", conds),
db.countWhereAsync("events", conds),
]);
Transactions get the same treatment through runTxn, which is the async twin of runSyncTxn and commits through the native Transaction.commitAsync(), so the commit itself, fsync and all, happens on the blocking pool rather than freezing your request handlers while the WAL hits disk.
Why the whole API is not async
Making every call async is the fashionable choice and it would have been the wrong one here, because most embedded database calls are measured in microseconds and the overhead of scheduling onto a thread pool, crossing the language boundary twice, and resolving a promise costs more than the work itself when the answer is already sitting in the page cache. We went through a version of this once before in a different life with PHP’s mysql_* functions, which were synchronous end to end and nobody minded, because PHP ran process-per-request and a blocking call only ever stalled the one request that issued it; a single-threaded Node server inverts that assumption, one blocking call stalls every request at once, so the calls that can actually touch disk have to earn their async form while the cheap ones stay plain. So the sync API stays the default for the hot, cheap operations, the Async siblings exist for the operations that can block, and you pick per call site with full knowledge of which is which, which is a less tidy story than “everything is a Promise” but a faster program at the end of it.
The fine print, because there is always fine print
Two caveats matter and both are documented in the API reference, but they deserve repeating because they are exactly the kind of thing you discover in production otherwise. First, the maintenance twins are not all real: compactAllAsync, compactTableAsync, approxAggregateAsync, and snapshotEpochAsync have no native async variant, so they wrap the synchronous call in a Promise and still block the loop while they run, matching the signature without matching the behavior. Second, the Kit-level async methods on TableHandle are raw I/O paths that bypass Kit’s constraint enforcement, meaning defaults, unique guards, and foreign-key checks do not fire on putAsync, which is the same contract the other high-throughput surfaces like WriteBuffer and bulkLoadTyped carry, so if you want constrained writes you use the transactional insert* builders and if you want speed you accept the trade with your eyes open.
When to reach for which
The rule of thumb we use internally is simple enough to fit on an index card: request-scoped server code, where the process is juggling other work, defaults to the async variants for anything that queries or commits, and scripts, migrations, and CLI tooling, where one operation at a time is the whole point, stay synchronous because the promise machinery buys nothing there. The desktop app that ships MongrelDB embedded follows the same split, keeping its UI thread clear with the async paths during scans and compactions while its import wizards call the sync API in a straight line, and neither side had to change a schema, a file format, or a transaction to do it, because both surfaces are the same engine all the way down. That is the actual feature here, not that async exists, since every database client eventually grows async methods, but that async is a per-call choice on one code path rather than a second API you migrate to and then maintain forever.