An Expo app's expense screen freezes while it loads a year of rows with expo-sqlite's getAllSync; why does that happen, and what would you change?
answer
- *Sync runs on the JS thread
- *Async runs on a native background queue
- rows still cross into JavaScript
- aggregate in SQL, not in JS
- page with LIMIT or stream with getEachAsync
basics
~20 sexpo-sqlite's *Sync methods run on the JavaScript thread, so nothing else in JavaScript runs until the whole year of rows is built. Use the async methods, which query on a native background queue, and read less: aggregate in SQL and page the list.
solid answer
~50 sEvery `*Sync` method in expo-sqlite (`openDatabaseSync`, `execSync`, `runSync`, `getAllSync`, the tag's `allSync()`) is a synchronous native call: it runs the query and builds the result before returning, **on the JavaScript thread**. The source marks each one with a warning that heavy tasks block the JS thread. A year of expenses means thousands of rows built into objects while no touch handler, state update or JS-driven animation can run. The `*Async` versions are dispatched to a native background queue (a dispatch queue on iOS, an IO coroutine scope on Android), so JavaScript keeps running. I would switch to `getAllAsync`, then reduce the data: compute monthly totals with `SUM` and `GROUP BY` in SQL, load the visible month's rows with `LIMIT`/paging, and use `getEachAsync` when I truly need to walk many rows. Sync calls stay acceptable for tiny, bounded reads.
go deeper
Recall that expo-sqlite methods ending in Sync block the JavaScript thread while they run, and that the Async versions do not.
Explain where each family runs (native background queue versus the JS thread) and why a large async result still costs time when it reaches JavaScript.
Diagnose the freeze and fix it by reading less: SQL aggregates, paging, streaming with getEachAsync, and a rule for when sync reads are acceptable.
Set the team's data-access rules: which screens may read synchronously, what result sizes are allowed, and how regressions are caught with realistic data.
## Two families of methods expo-sqlite offers most operations twice: | Async | Sync | |---|---| | `openDatabaseAsync` | `openDatabaseSync` | | `execAsync` | `execSync` | | `runAsync` | `runSync` | | `getFirstAsync` / `getAllAsync` / `getEachAsync` | `getFirstSync` / `getAllSync` / `getEachSync` | | `prepareAsync`, `executeAsync`, `finalizeAsync` | `prepareSync`, `executeSync`, `finalizeSync` | | ``await db.sql`...` `` | ``db.sql`...`.allSync()`` | They differ in **where the SQLite work happens**: - **Async** functions are registered as asynchronous native functions and scheduled on a background queue: on iOS a dedicated concurrent dispatch queue, on Android a coroutine scope on the IO dispatcher. JavaScript receives a Promise and carries on. - **Sync** functions run to completion **on the JavaScript thread** before returning. The library's own doc comments warn: "Running heavy tasks with this function can block the JavaScript thread and affect performance." ## Why the screen freezes `getAllSync` on a year of expenses does three things before returning: steps through every matching row in SQLite, converts each row's columns into JavaScript values, and composes them into row objects. While that happens the JavaScript thread is busy, so touch handlers, `setState` updates and JS-driven animations wait. The user sees a frozen screen. (Why a busy JavaScript thread shows up as dropped frames is a React Native threading topic of its own.) ## Fixing it, in order of impact 1. **Stop loading what the screen does not show.** A yearly overview needs twelve totals, not thousands of rows: ```typescript const totals = await db.getAllAsync<{ month: string; total: number }>( `SELECT substr(spent_on, 1, 7) AS month, SUM(amount_cents) AS total FROM expenses WHERE spent_on >= ? AND spent_on < ? GROUP BY month ORDER BY month`, "2026-01-01", "2027-01-01" ); ``` 2. **Page the detail list.** Load the visible month, or use `LIMIT ? OFFSET ?` (or a keyset condition) as the user scrolls. 3. **Use the async methods.** `getAllAsync` moves the SQLite work off the JavaScript thread. The resulting array still has to be delivered to JavaScript, so a huge result remains expensive even when async; that is why steps 1 and 2 come first. 4. **Stream when you must walk many rows.** `getEachAsync` (or `for await` over a statement result) fetches rows one at a time, which keeps memory flat for exports or bulk processing. ## Before and after The freezing version reads synchronously while rendering: ```tsx function YearScreen({ db }: { db: SQLiteDatabase }) { const rows = db.getAllSync<Expense>("SELECT * FROM expenses WHERE spent_on >= ?", "2026-01-01"); return <ExpenseList rows={rows} />; } ``` The fixed version asks for the aggregate asynchronously, shows a placeholder for the moment it takes, and loads month details only when a month is opened: ```tsx // useEffect/useState from "react", ActivityIndicator from "react-native", type SQLiteDatabase from "expo-sqlite" function YearScreen({ db }: { db: SQLiteDatabase }) { const [totals, setTotals] = useState<MonthTotal[] | null>(null); useEffect(() => { db.getAllAsync<MonthTotal>(MONTH_TOTALS_SQL, "2026-01-01", "2027-01-01").then(setTotals); }, [db]); if (totals === null) return <ActivityIndicator />; return <MonthTotalsList totals={totals} />; } ``` The amount of data crossing into JavaScript drops from thousands of rows to twelve, and none of the SQLite work runs on the JavaScript thread. ## When sync is reasonable Sync calls are not banned. They are convenient where the work is tiny and bounded and an `await` would complicate code, for example reading one settings row at startup or inside code that cannot be async. Rules of thumb: - Bounded by design (a primary-key lookup, a `LIMIT 1`, a `COUNT`), not by today's data size. - Never in a loop over user data. - Never on a screen transition or gesture path. ## Diagnosing it in a real app - Look for `*Sync` calls and ``.allSync()`` in code that runs during rendering or navigation. - Time the call with realistic data on a low-end Android device; development data is usually tiny. - Check whether the screen needs rows at all, or only aggregates the database can compute faster. - Remember the tagged template's helpers: ``.allSync()`` and ``.firstSync()`` are sync calls too, and they hang off the query object rather than the database, so a search for database methods ending in `Sync` misses them. - Re-test after the fix with the largest realistic data set, since the async version can still stall if the result it delivers is huge.
- If getAllAsync runs off the JavaScript thread, why can a huge query still cause jank?Only the SQLite work moves to a background queue. The finished result is still delivered to JavaScript as thousands of row objects, and whatever the screen then does with them (sorting, mapping, rendering) runs on the JavaScript thread. Reading less data fixes that; switching to async alone does not.
- When is openDatabaseSync a reasonable choice?When the app needs a database handle synchronously, for example at module load, and opening is cheap. It still runs on the JavaScript thread, so keep it off interaction paths; for most screens `openDatabaseAsync` in an effect is the simpler default.
saying these in an interview costs you the question
- Believing *Sync methods run on a background thread and only return synchronously
- Assuming getAllAsync makes a huge result free
- Summing a year of rows in JavaScript instead of in SQL
- Using getAllSync inside a loop over user data
- Banning sync calls even for bounded single-row reads