- I hit 280–600ms input lag on set logging. Brutal.
- I fixed it with a tiny write-ahead queue + batched SQLite writes.
- I stopped rerenders by isolating the input row and memoizing selectors.
- All on Expo + SQLite. Offline-first. No network required.
Context
I’m building a fitness app where logging a set must feel instant. Like. Tap. Type. Done.
My target was “you can log a set in ~5 seconds”. That only works if the UI reacts in under 100ms. Otherwise you get that sticky keyboard feeling. You know the one.
I started with the obvious approach: on every keystroke, write to SQLite. It worked offline. It was correct. And it made the UI lag.
Spent 4 hours “optimizing queries”. Most of it was wrong. The real problem wasn’t the SQL. It was the timing.
What finally worked: treat the UI as the source of truth for a moment, then persist to SQLite in batches.
1) I stopped writing on every keystroke
I was doing this:
- user types
8 -
onChangeTextfires - I
UPDATE sets SET reps = 8 ... - React re-renders
- keyboard stutters
SQLite is fast. But not “every keystroke plus re-render” fast.
So I split the concerns.
- UI updates immediately (state)
- persistence happens a bit later
This is the core: a write queue that batches mutations.
// db/writeQueue.ts
import type { SQLiteDatabase } from "expo-sqlite";
type SQLItem = {
sql: string;
args?: (string | number | null)[];
};
let queue: SQLItem[] = [];
let timer: ReturnType | null = null;
export function enqueueWrite(db: SQLiteDatabase, item: SQLItem) {
queue.push(item);
// Debounce. One flush per burst.
if (timer) clearTimeout(timer);
timer = setTimeout(() => flush(db), 120);
}
async function flush(db: SQLiteDatabase) {
const batch = queue;
queue = [];
timer = null;
if (batch.length === 0) return;
// One transaction for the burst.
await db.withTransactionAsync(async () => {
for (const q of batch) {
await db.runAsync(q.sql, q.args ?? []);
}
});
}
120ms wasn’t magic. I tried 50ms. Too many flushes.
I tried 300ms. Felt like data loss when I force-quit.
120ms was the sweet spot on my Android test device.
2) I made the row “optimistic”, then reconciled
If the UI waits for SQLite, you lose.
So each set row holds local state for the inputs. And only commits to SQLite through the queue.
I also learned the hard way: don’t tie TextInput.value to a selector that re-runs on every DB change. That causes the cursor jump bug. The one where typing deletes your own characters.
This pattern fixed it.
// components/SetRow.tsx
import React from "react";
import { TextInput, View, Text } from "react-native";
import type { SQLiteDatabase } from "expo-sqlite";
import { enqueueWrite } from "../db/writeQueue";
type Props = {
db: SQLiteDatabase;
setId: string;
initialReps: number | null;
initialWeight: number | null;
};
export const SetRow = React.memo(function SetRow({
db,
setId,
initialReps,
initialWeight,
}: Props) {
const [reps, setReps] = React.useState(String(initialReps ?? ""));
const [weight, setWeight] = React.useState(String(initialWeight ?? ""));
const onRepsChange = (t: string) => {
setReps(t); // UI first
const v = t.trim() === "" ? null : Number(t);
if (t.trim() !== "" && Number.isNaN(v)) return;
enqueueWrite(db, {
sql: "UPDATE sets SET reps = ?, updated_at = strftime('%s','now') WHERE id = ?",
args: [v, setId],
});
};
const onWeightChange = (t: string) => {
setWeight(t);
const v = t.trim() === "" ? null : Number(t);
if (t.trim() !== "" && Number.isNaN(v)) return;
enqueueWrite(db, {
sql: "UPDATE sets SET weight = ?, updated_at = strftime('%s','now') WHERE id = ?",
args: [v, setId],
});
};
return (
Reps
Weight
);
});
One thing that bit me — decimal-pad on Android still lets weird input through depending on keyboard. So I validate with Number() anyway.
And yeah, I’m writing updated_at on every change. That matters later for sync.
3) I stopped the whole list from rerendering
My first version had a parent component reading the DB rows and rendering a FlatList.
Every queued write updated the DB. Every DB update refreshed the parent state. Every refresh rerendered every row.
It wasn’t obvious until I turned on render tracing.
So I changed the data flow:
- parent loads the workout sets once
- rows keep local input state
- the parent only refreshes when the screen focuses, or when I add/remove sets
I’m using Expo Router, so focus events are easy.
// hooks/useWorkoutSets.ts
import { useCallback, useEffect, useState } from "react";
import { useFocusEffect } from "expo-router";
import type { SQLiteDatabase } from "expo-sqlite";
export type SetRowModel = {
id: string;
reps: number | null;
weight: number | null;
};
export function useWorkoutSets(db: SQLiteDatabase, workoutId: string) {
const [rows, setRows] = useState([]);
const load = useCallback(async () => {
const result = await db.getAllAsync(
"SELECT id, reps, weight FROM sets WHERE workout_id = ? ORDER BY position ASC",
[workoutId]
);
setRows(result);
}, [db, workoutId]);
useEffect(() => {
load();
}, [load]);
// Reload when user comes back to the screen.
useFocusEffect(
useCallback(() => {
load();
}, [load])
);
return { rows, reload: load };
}
This sounds like I’m “ignoring DB changes”. I am.
But only for the hot path: typing.
If I need to show the latest computed stuff (like volume totals), I compute it from local state or I update on blur.
Cursor also stops jumping. That alone was worth it.
4) I turned SQLite into “fast mode” (safely)
Expo SQLite defaults are fine. But I still needed to squeeze a bit.
Two pragmas helped:
- WAL mode
- NORMAL synchronous
This is still durable enough for my use case. The app is offline-first. But it’s not a bank.
I run this once at startup.
// db/init.ts
import type { SQLiteDatabase } from "expo-sqlite";
export async function initDb(db: SQLiteDatabase) {
// Better concurrency. Faster writes.
await db.execAsync("PRAGMA journal_mode = WAL;");
// Faster. Accepts tiny risk on sudden power loss.
await db.execAsync("PRAGMA synchronous = NORMAL;");
// Keeps the file from growing forever.
await db.execAsync("PRAGMA wal_autocheckpoint = 1000;");
await db.execAsync(`
CREATE TABLE IF NOT EXISTS sets (
id TEXT PRIMARY KEY NOT NULL,
workout_id TEXT NOT NULL,
position INTEGER NOT NULL,
reps INTEGER,
weight REAL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sets_workout_pos
ON sets(workout_id, position);
`);
}
Don’t skip the index.
I did. Once.
Scrolling a workout with 42 sets went from smooth to sticky, because I was sorting without an index.
Results
Before the changes, set logging felt delayed. Measured with a quick timestamp diff in the input handler, I was seeing 280–600ms between typing and the UI settling on my Android device (Pixel 6a). On iOS (iPhone 13), it was better but still noticeable at 140–220ms.
After adding the write queue, isolating row state, and preventing full list rerenders, the same measurement dropped to 28–75ms on Android and 18–52ms on iOS. I also reduced SQLite writes during a typical 12-set workout from 312 statements (every keystroke) to 74 statements (batched bursts + final values).
Key takeaways
- Don’t write to SQLite on every keystroke. Queue it.
- Keep
TextInput.valuelocal, or your cursor will jump. - Reload the list on focus, not on every write.
- Use WAL + an index on your “workout_id, position” access path.
- Measure the lag. I used timestamps. Not vibes.
Closing
If you’re doing offline-first in React Native: do you persist on onChangeText, on onBlur, or with a debounced queue like this? And what debounce value (50ms, 120ms, 300ms) actually felt right on your slowest Android device?
Top comments (0)