In expo-sqlite, why wrap several related writes in db.withTransactionAsync, and what happens when one of them throws?
answer
- all-or-nothing, not per statement
- BEGIN, await task, COMMIT
- throw means ROLLBACK then rethrow
- swallowed error still commits
- one commit beats hundreds
basics
~10 swithTransactionAsync runs BEGIN, awaits your callback, then COMMIT; if the callback throws it runs ROLLBACK and rethrows. Related writes land all-or-nothing, and one commit for a batch is much faster than autocommitting every statement.
solid answer
~40 sWithout a transaction every `runAsync` is its own autocommit, so a journal entry could be saved while its tag rows fail, and a bulk insert pays one commit per row. `await db.withTransactionAsync(async () => { ... })` issues `BEGIN`, awaits the callback, then `COMMIT`; if the callback rejects, it issues `ROLLBACK` and rethrows, so the caller still sees the error. Two traps: an error you catch and log inside the callback never reaches it, so it commits the partial work; and an un-awaited query may run after `COMMIT`, outside the transaction. The callback takes no argument, you keep calling the same `db`, and it resolves to `void`. It is also not exclusive: other queries on that `db` while it is open join it, which is what `withExclusiveTransactionAsync` exists to prevent.
code
typescript · 17 linesimport type { SQLiteDatabase } from 'expo-sqlite';
export async function saveEntry(db: SQLiteDatabase, body: string, tags: string[]): Promise<number> {
let entryId = 0;
await db.withTransactionAsync(async () => {
const { lastInsertRowId } = await db.runAsync(
'INSERT INTO entries (body, created_at) VALUES (?, ?)',
body,
Date.now()
);
entryId = lastInsertRowId;
for (const tag of tags) {
await db.runAsync('INSERT INTO entry_tags (entry_id, tag) VALUES (?, ?)', entryId, tag);
}
});
return entryId;
}go deeper
Recall the shape: BEGIN, await the callback, COMMIT, and ROLLBACK plus rethrow when it throws. Say why a group of related writes must be all-or-nothing.
Explain why a caught error commits partial work and an un-awaited query can escape the transaction, and why one commit makes bulk inserts fast.
Show you keep transactions short and owned by one function, never await network or UI inside one, and know the plain variant pulls in concurrent queries on the same db.
Frame transaction boundaries as part of the data-access design: one module owns writes, so atomic units and their failure handling are decided once rather than per screen.
## What problem a transaction solves in expo-sqlite `expo-sqlite` gives a React Native or Expo app a real SQLite database file on the device. When you call `db.runAsync(...)` on its own, SQLite runs that statement in **autocommit** mode: the statement is its own tiny transaction and is made durable the moment it finishes. That is fine for one write, and wrong for a group of writes that only make sense together. Take a journaling app that saves an entry and then one row per tag in an `entry_tags` table. If the entry insert succeeds and the third tag insert fails (a constraint, a bad value, the app being killed), the user is left with an entry that is missing tags. Nothing in the app will ever repair it, because every statement that ran was committed. A **transaction** groups statements so they are applied **all-or-nothing**: either every write commits together, or none of them is kept. ## What withTransactionAsync actually does The implementation in `expo-sqlite` 57 is short enough to state exactly: 1. It calls `execAsync('BEGIN')`. 2. It `await`s the async function you passed (the **task**). 3. If the task resolves, it calls `execAsync('COMMIT')`. 4. If anything in that sequence throws, it calls `execAsync('ROLLBACK')` and **rethrows** the original error. So the promise returned by `withTransactionAsync` rejects with your error, and the caller can show a message or retry. Some details matter in an interview: - **The task receives no argument.** Inside it you keep using the same `db` object, unlike `withExclusiveTransactionAsync`, whose task receives a separate `txn` object. - **The task's type is `() => Promise<void>`** and the method resolves to `void`. To get a value out (for example, the new entry id), assign it to a variable declared outside the callback. - **It is not exclusive.** It works on the connection that `db` wraps, so any other query awaited on that same `db` while the transaction is open becomes part of it. The expo-sqlite docs call this out explicitly and point to `withExclusiveTransactionAsync` for the fix. - **There is a synchronous twin**, `withTransactionSync`, with the same BEGIN, COMMIT or ROLLBACK shape; it runs on the JavaScript thread and blocks it for as long as the work takes. ## The two mistakes that silently break atomicity **Catching inside the callback.** `withTransactionAsync` only rolls back when the task rejects. If you wrap a `runAsync` in `try/catch`, log the error and carry on, the task resolves normally and `COMMIT` runs. Whatever succeeded before the failure is now permanent. If you need to catch for logging, rethrow. **Forgetting `await`.** A query that is started but not awaited is not ordered with respect to `COMMIT`. It may run after the commit, outside the transaction, and if it fails, its rejection is not what the task returns, so no rollback happens. Every statement inside the task should be awaited. ## Why it is also faster Each autocommit write ends with a durable commit, which is the expensive part of a write on a phone's flash storage. Inserting a thousand rows one by one pays that cost a thousand times; inside one transaction it is paid once. For bulk imports this is commonly the largest single speed-up available, before any prepared-statement tuning. | | Separate `runAsync` calls | Inside `withTransactionAsync` | |---|---|---| | Failure halfway | earlier writes stay committed | every write is rolled back | | Commits for N writes | N | 1 | | Error seen by caller | only from the failing call | rethrown from the whole block | | Other queries on `db` meanwhile | independent | join the open transaction | ## Nesting and scope SQLite does not nest `BEGIN`. Calling `withTransactionAsync` from inside another `withTransactionAsync` makes the inner `BEGIN` fail because a transaction is already active. The inner wrapper's `ROLLBACK` then undoes the outer transaction's work, and the outer wrapper's own `ROLLBACK` fails because nothing is open any more, so the caller sees that confusing error instead of the original one. Design one outer function that owns the transaction and have helpers take the `db` and run plain statements. SQLite's own savepoints exist for partial rollback, but they are SQL you write yourself through `execAsync`, not an expo-sqlite API. Keep the task short and local: no network requests, no waiting for user input. While it is open, everything else the app runs on that `db` is pulled into it.
- Why is inserting a thousand rows much faster inside withTransactionAsync?Outside a transaction each `runAsync` autocommits, and every commit has to make the write durable on flash storage. Inside one transaction the statements share a single `COMMIT`, so that cost is paid once instead of a thousand times.
- How do you get a value such as the new row id out of withTransactionAsync?The task is typed `() => Promise<void>` and the method resolves to `void`, so you cannot return through it. Declare a variable outside the callback, assign `lastInsertRowId` to it inside, and read it after the `await` resolves.
- What happens if a helper called inside withTransactionAsync opens its own withTransactionAsync?SQLite does not nest `BEGIN`, so the inner `BEGIN` fails. The inner wrapper's `ROLLBACK` undoes the outer work, then the outer wrapper's `ROLLBACK` fails with no transaction open, so the caller gets a misleading error. Keep one owner of the transaction; helpers run plain statements.
saying these in an interview costs you the question
- withTransactionAsync keeps the writes that succeeded before a later one throws
- Catching and logging an error inside the callback still triggers a rollback
- An un-awaited runAsync inside the callback is always part of the transaction
- withTransactionAsync calls can be nested freely, like savepoints
- withTransactionAsync shields the callback from every other query on the same db