Bulk writes
How to insert or update many rows efficiently, why Promise.all() with many run() calls is not
a bulk-write API, and when to reach for runBatch() versus runMany().
The anti-pattern: fanning out run() calls
// Don't do this for hundreds/thousands of rows.
await Promise.all(rows.map((r) => CapacitorSqlite.run({ database: 'myapp', statement: '...', values: r })));
An explicit transaction removes repeated SQLite commit/fsync cost, but it does not remove the
JS→native bridge round trip for every individually awaited run(). Promise.all() over
thousands of run() calls still creates one bridge request, one native queue entry, one result
object, and one JavaScript callback per row — it is a bridge fan-out benchmark, not a
bulk-insert primitive. It also means mixed read/write traffic against the same database is
serialized by design (see Platform notes), so a large write fan-out can
delay reads submitted at the same time.
For hundreds or thousands of rows, send them through runBatch() (mixed SQL) or runMany()
(one repeated statement) instead.
runBatch(): mixed SQL in one call
Executes multiple parameterized statements in a single native call — for batches where the SQL differs between items.
await CapacitorSqlite.runBatch({
database: 'myapp',
set: [
{ statement: 'INSERT INTO users (name) VALUES (?)', values: ['Bob'] },
{ statement: 'INSERT INTO users (name) VALUES (?)', values: ['Carol'] },
],
});
Wrapped in a single transaction by default (transaction: true). With that default, runBatch()
validates the complete input set before the first write; Android and iOS reuse one prepared
statement for repeated identical SQL and read the connection change counter once around the
whole batch, avoiding per-row prepare/metadata overhead without changing atomicity or the
returned changes. transaction: false keeps prior successful statements committed if a later
one fails, and is required when calling runBatch() from inside an already-active manual
transaction (see Transactions). lastInsertId on the aggregate
result is always 0 — use run() when the inserted row's id is needed.
runMany(): one statement, many value sets
Executes one parameterized statement once per value set, in a single plugin call. Prefer this
over runBatch() when every operation shares the same SQL — the statement text is transported
and classified only once instead of once per item.
await CapacitorSqlite.runMany({
database: 'myapp',
statement: 'INSERT INTO users (name) VALUES (?)',
values: [['Bob'], ['Carol']],
});
Android, iOS, and Electron reuse one prepared statement for the whole loop; Web's sqlite-wasm
Worker1 doesn't expose a persistent prepared-statement handle but still benefits from the compact
single-SQL request shape. All value sets are validated before the first write, and the operation
is atomic by default — pass transaction: false to preserve earlier successful executions if a
later one fails.
lastInsertId on the aggregate result is always 0. Set returnResults: true only when each
execution's changes/lastInsertId is actually needed — omitting it minimizes bridge output and
maximizes throughput.
Electron compatibility note: an app whose generated main/preload plugin registry predates
runMany() still works — the JS wrapper falls back to an equivalent runBatch()/run()-loop
implementation automatically — but regenerate that registry after upgrading to get the faster
native repeated-statement path.
runBatch() vs runMany() vs a manual transaction
| Use | When |
|---|---|
runMany() |
Every operation uses the same SQL statement. |
runBatch() |
Operations use different SQL statements. |
run() inside beginTransaction()/commitTransaction() |
You need per-row results interleaved with other application logic, or need to combine writes across multiple statements/tables inside one larger unit of work. |
Web/OPFS-specific durability cost
On Web, each successful autocommit run() write includes a browser OPFS durability barrier —
this is intentional durability, not SQL planning time or general plugin overhead. Looping
individual autocommit run() calls (or fanning them out with Promise.all()) multiplies that
per-row durable-commit cost. Group writes with runMany(), runBatch(), or an explicit
transaction to pay that barrier once for the whole group instead of once per row.
Indexes still matter
The plugin does not create indexes automatically or rewrite application SQL. A filtered query
such as WHERE id > ? ORDER BY id DESC LIMIT 100 scans and sorts the whole table without a
matching index, regardless of batching. Add an index on the filter/order columns
(CREATE INDEX ... ON table(id)) when this pattern is latency-sensitive — EXPLAIN QUERY PLAN
reports the unindexed cost as SCAN ... and USE TEMP B-TREE FOR ORDER BY.
Next step
Platform notes documents the underlying serialization model these guidelines are built on, and the full set of cross-platform caveats.