- I stopped blocking my UI on SQLite writes.
- I batch writes in-memory, then flush every 250ms.
- I still guarantee durability on app background.
- Copy-paste hook + SQLite schema + flush logic inside.
Context
My fitness app logs sets fast. Like “tap, tap, done”. I timed myself. 5 seconds per set entry is the bar.
Offline-first means SQLite. Not AsyncStorage. I need queries like “last 3 workouts for chest” and “total volume by week”. SQLite wins.
But I hit a brutal issue. I was doing a DB write on every tap. Reps change? Write. Weight change? Write. Mark set done? Write.
On a warm device it felt fine. On a slightly busy device it stuttered. The UI thread wasn’t “blocked” technically, but React state updates were waiting behind JS work + bridge work + DB calls. End result: dropped frames.
So I changed the rule.
UI state updates are immediate.
DB writes are delayed and batched.
And I still don’t lose sets.
1) I measured the stutter. Then I got mad.
I started with numbers. Not vibes.
I added a little timer around my “save set” function. I also watched the JS FPS in the RN dev menu.
The ugly part: my save path did multiple awaits. Some were fast. Some weren’t. Worst case was 40–80ms per tap. That’s enough to feel sticky when you’re tapping fast.
Here’s the kind of write I was doing. It’s not “wrong”. It’s just too eager.
// setRepo.ts
import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabaseSync("gym.db");
export type SetRow = {
id: string;
workout_id: string;
exercise_id: string;
set_index: number;
weight_kg: number;
reps: number;
is_done: 0 | 1;
updated_at: number;
};
export async function upsertSet(row: SetRow) {
const t0 = globalThis.performance.now();
db.runSync(
`INSERT INTO sets (id, workout_id, exercise_id, set_index, weight_kg, reps, is_done, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
weight_kg=excluded.weight_kg,
reps=excluded.reps,
is_done=excluded.is_done,
updated_at=excluded.updated_at`,
[
row.id,
row.workout_id,
row.exercise_id,
row.set_index,
row.weight_kg,
row.reps,
row.is_done,
row.updated_at,
]
);
const ms = globalThis.performance.now() - t0;
if (ms > 16) console.log(`[db] upsertSet took ${ms.toFixed(1)}ms`);
}
My mistake: calling this on every tap.
I spent 4 hours trying to “optimize SQL”. Most of it was wrong.
The fix was architectural.
2) I made the UI the source of truth
I stopped treating SQLite as the live state.
Now my UI state updates immediately. I collect pending changes in memory. Then I flush to SQLite on a timer.
That does two things.
First, the user never waits for a DB call.
Second, I get batching for free.
This is the hook I ended up with. It’s small. It’s boring. It works.
// useDebouncedSQLiteWriter.ts
import { useEffect, useMemo, useRef } from "react";
import * as SQLite from "expo-sqlite";
import { AppState } from "react-native";
type Job = { sql: string; args: SQLite.SQLiteBindValue[] };
export function useDebouncedSQLiteWriter(opts?: {
flushEveryMs?: number;
maxQueue?: number;
}) {
const flushEveryMs = opts?.flushEveryMs ?? 250;
const maxQueue = opts?.maxQueue ?? 500;
const db = useMemo(() => SQLite.openDatabaseSync("gym.db"), []);
const queueRef = useRef([]);
const flushingRef = useRef(false);
const enqueue = (sql: string, args: SQLite.SQLiteBindValue[] = []) => {
const q = queueRef.current;
q.push({ sql, args });
// If someone goes wild tapping, don't grow forever.
if (q.length > maxQueue) q.splice(0, q.length - maxQueue);
};
const flush = () => {
if (flushingRef.current) return;
const jobs = queueRef.current.splice(0);
if (!jobs.length) return;
flushingRef.current = true;
try {
db.withTransactionSync(() => {
for (const j of jobs) db.runSync(j.sql, j.args);
});
} finally {
flushingRef.current = false;
}
};
useEffect(() => {
const id = setInterval(flush, flushEveryMs);
// Durability: if the app backgrounds, flush right now.
const sub = AppState.addEventListener("change", (s) => {
if (s !== "active") flush();
});
return () => {
clearInterval(id);
sub.remove();
flush();
};
}, [flushEveryMs]);
return { enqueue, flush };
}
Two details mattered.
withTransactionSync. That’s the batching.
And AppState flush on background. Because iOS will happily kill you after you swipe away.
If you skip that background flush, you’ll lose the last few taps. Ask me how I know.
3) I made writes idempotent. Offline-first demands it.
Debouncing is great until you crash mid-flight.
So I treat every write as “replayable”. If the same set update gets enqueued twice, it shouldn’t corrupt anything.
That means:
- stable IDs (UUID for each set)
ON CONFLICT(id) DO UPDATE-
updated_atstored as epoch ms
Here’s the schema I’m using for sets. It’s not fancy. It’s fast.
-- schema.sql
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS sets (
id TEXT PRIMARY KEY,
workout_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
set_index INTEGER NOT NULL,
weight_kg REAL NOT NULL,
reps INTEGER NOT NULL,
is_done INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sets_workout
ON sets(workout_id, set_index);
CREATE INDEX IF NOT EXISTS idx_sets_exercise
ON sets(exercise_id, updated_at);
WAL mode helped. Noticeably.
And yeah, synchronous = NORMAL is a tradeoff. I’m fine with it for a workout log. If someone’s phone hard-crashes mid-set, the last tap might go missing. My background flush reduces that a lot.
4) I wired it into the “tap to log” UI
This is the part that made it feel instant.
The UI updates local state first. Then it schedules a write.
I’m using a reducer for the workout screen. But the pattern works with useState too.
// SetRow.tsx
import React from "react";
import { Pressable, Text, View } from "react-native";
import { useDebouncedSQLiteWriter } from "./useDebouncedSQLiteWriter";
type SetModel = {
id: string;
workoutId: string;
exerciseId: string;
setIndex: number;
weightKg: number;
reps: number;
isDone: boolean;
};
export function SetRow({ set, onChange }: { set: SetModel; onChange: (next: SetModel) => void }) {
const { enqueue } = useDebouncedSQLiteWriter({ flushEveryMs: 250 });
const persist = (next: SetModel) => {
enqueue(
`INSERT INTO sets (id, workout_id, exercise_id, set_index, weight_kg, reps, is_done, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
weight_kg=excluded.weight_kg,
reps=excluded.reps,
is_done=excluded.is_done,
updated_at=excluded.updated_at`,
[
next.id,
next.workoutId,
next.exerciseId,
next.setIndex,
next.weightKg,
next.reps,
next.isDone ? 1 : 0,
Date.now(),
]
);
};
return (
{set.setIndex + 1}
{
const next = { ...set, reps: set.reps + 1 };
onChange(next); // UI first
persist(next); // DB later
}}
style={{ padding: 10, backgroundColor: "#222" }}
>
+rep
{
const next = { ...set, isDone: !set.isDone };
onChange(next);
persist(next);
}}
style={{ padding: 10, backgroundColor: set.isDone ? "#0a0" : "#444" }}
>
{set.isDone ? "done" : "mark"}
);
}
The important part is psychological.
Taps feel immediate.
Then the DB catches up.
One thing that bit me — don’t mount this hook per row like I did first. That created multiple intervals. Dumb.
In my app, I mount useDebouncedSQLiteWriter once at the screen level and pass { enqueue } down via props/context.
If you copy-paste this, do that.
Results
Before this change, my workout screen would stutter if I tapped fast. I could reliably reproduce it by hammering “+rep” 20 times in a row. I saw 30–80ms logs from upsertSet and the UI felt sticky.
After switching to debounced writes, the tap handler does zero awaited work. My UI stays smooth even during rapid taps. The DB now flushes at most 4 times per second, and each flush typically batches 6–25 statements in one transaction.
I also stopped losing “last tap” data when backgrounding, because I flush on AppState changes.
Key takeaways
- Don’t write to SQLite on every tap. Batch it.
-
withTransactionSyncis the difference between “fine” and “fast”. - Make writes idempotent (
ON CONFLICT DO UPDATE) so retries don’t hurt. - Always flush on app background/unmount, or you’ll lose the last few edits.
- Mount one writer per screen, not per component row.
Closing
If you’re doing offline-first in React Native: do you debounce writes like this, or do you go full event-sourcing (append-only table + compaction) for durability?
Top comments (0)