- I ship offline exercise search with SQLite FTS5.
- I keep typing smooth with a debounced query.
- I highlight matches and rank results, fast.
- I learned why
%LIKE%is a trap on mobile.
Context
My fitness app has ~400 exercises. Each has a name, muscle group, and a GIF.
Search is the main screen. People open the app mid-workout. They type fast. They expect results instantly.
I started with LIKE '%query%' on a normal SQLite table. Brutal. It worked with 40 rows. Then I imported the real dataset. Typing dropped frames. My “5-second set logging” promise fell apart because the search screen felt sticky.
Also: offline-first. No server search. No Algolia. No “just hit an endpoint.”
So I moved search to SQLite FTS5. Same local DB. Real full-text index. And I stopped doing work on every keystroke.
1) I stopped using LIKE. I indexed.
LIKE feels simple. It’s also a full table scan when you wrap the query in %...%.
On a phone. While React is trying to render.
FTS5 fixed it. But only after I wired it correctly.
I keep a normal exercises table for the source of truth. Then an FTS virtual table for search. Then triggers to keep them in sync.
Code. This is the exact schema setup.
// db/schema.ts
import * as SQLite from "expo-sqlite";
export async function initDb(db: SQLite.SQLiteDatabase) {
// Base table
await db.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS exercises (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
primary_muscle TEXT NOT NULL,
equipment TEXT NOT NULL
);
-- Full-text index. content=exercises lets us join back.
CREATE VIRTUAL TABLE IF NOT EXISTS exercises_fts
USING fts5(
name,
primary_muscle,
equipment,
content='exercises',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
-- Keep FTS in sync.
CREATE TRIGGER IF NOT EXISTS exercises_ai AFTER INSERT ON exercises BEGIN
INSERT INTO exercises_fts(rowid, name, primary_muscle, equipment)
VALUES (new.id, new.name, new.primary_muscle, new.equipment);
END;
CREATE TRIGGER IF NOT EXISTS exercises_ad AFTER DELETE ON exercises BEGIN
INSERT INTO exercises_fts(exercises_fts, rowid, name, primary_muscle, equipment)
VALUES ('delete', old.id, old.name, old.primary_muscle, old.equipment);
END;
CREATE TRIGGER IF NOT EXISTS exercises_au AFTER UPDATE ON exercises BEGIN
INSERT INTO exercises_fts(exercises_fts, rowid, name, primary_muscle, equipment)
VALUES ('delete', old.id, old.name, old.primary_muscle, old.equipment);
INSERT INTO exercises_fts(rowid, name, primary_muscle, equipment)
VALUES (new.id, new.name, new.primary_muscle, new.primary_muscle, new.equipment);
END;
`);
}
Yes, I made a dumb mistake there the first time.
I had new.primary_muscle twice (copy/paste brain). Search results were “fine” but equipment filtering was wrong. Spent 2 hours thinking FTS was broken. It was me.
Also: WAL matters. Without it, I hit random “database is locked” during writes + reads. WAL reduced that pain a lot.
2) I query FTS5 like a grown-up
FTS query syntax is not SQL LIKE.
I want prefix matching. If someone types ben, I want bench press.
FTS does that with *. So I build ben*.
But. If you shove raw user input into FTS, you’ll get parser errors.
I hit this exact error:
SQLITE_ERROR: fts5: syntax error near "-"
Someone typed t-bar.
So I sanitize. And I keep the query small.
// db/search.ts
import * as SQLite from "expo-sqlite";
function ftsPrefixQuery(input: string) {
// Keep letters/numbers/spaces. Drop punctuation that breaks FTS.
const cleaned = input
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.trim();
if (!cleaned) return "";
// "bench press" -> "bench* press*"
return cleaned
.split(/\s+/)
.filter(Boolean)
.map((t) => `${t}*`)
.join(" ");
}
export async function searchExercises(
db: SQLite.SQLiteDatabase,
input: string,
limit = 30
) {
const q = ftsPrefixQuery(input);
if (!q) return [] as any[];
// bm25() gives decent ranking for free.
const rows = await db.getAllAsync(
`
SELECT e.id, e.name, e.primary_muscle, e.equipment,
bm25(exercises_fts) AS rank
FROM exercises_fts
JOIN exercises e ON e.id = exercises_fts.rowid
WHERE exercises_fts MATCH ?
ORDER BY rank
LIMIT ?;
`,
[q, limit]
);
return rows;
}
bm25() is underrated. I used to hand-roll scoring. Total waste.
And yes, I sort ascending. Smaller bm25 score ranks higher.
One more thing that bit me — FTS tokenization.
If your dataset has incline bench and users type inclined, stemming won’t happen by default. SQLite FTS5 doesn’t magically do stemming unless you add a tokenizer that supports it. I didn’t. I accepted it. I’d rather be predictable than “smart” and wrong.
3) I debounced typing. Otherwise you DDOS your own DB
Even with FTS, doing a query on every keystroke is noisy.
React Native renders. The keyboard fires events. Your JS thread gets busy. Then you blame SQLite.
I debounce at 120ms. Not 500ms. That feels laggy.
And I cancel stale requests so results don’t flicker.
// hooks/useDebouncedValue.ts
import { useEffect, useState } from "react";
export function useDebouncedValue(value: T, delayMs: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
// screens/ExerciseSearchScreen.tsx
import React, { useEffect, useMemo, useState } from "react";
import { Text, TextInput, View, FlatList } from "react-native";
import * as SQLite from "expo-sqlite";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { searchExercises } from "../db/search";
const db = SQLite.openDatabaseSync("gym.db");
export function ExerciseSearchScreen() {
const [text, setText] = useState("");
const debounced = useDebouncedValue(text, 120);
const [rows, setRows] = useState([]);
// Simple request id. Prevent stale async updates.
const requestIdRef = React.useRef(0);
useEffect(() => {
let alive = true;
const reqId = ++requestIdRef.current;
(async () => {
if (!debounced.trim()) {
setRows([]);
return;
}
const res = await searchExercises(db, debounced, 30);
if (!alive) return;
if (reqId !== requestIdRef.current) return;
setRows(res);
})();
return () => {
alive = false;
};
}, [debounced]);
const renderItem = ({ item }: { item: any }) => (
{item.name}
{item.primary_muscle} • {item.equipment}
);
return (
String(x.id)}
renderItem={renderItem}
/>
);
}
This is boring code. Good.
Before debounce, I could see 20+ queries in a second while typing. After debounce, it’s usually 4–6 queries for a typical search.
And the UI stopped feeling “wet.” You know that feeling. Like you’re dragging the app through mud.
4) I highlight matches without doing fancy stuff
People scan.
If I show bench press and they typed ben, their eyes want confirmation.
FTS5 can return offsets via offsets() but parsing that output is annoying. I tried. Spent 4 hours on it. Most of it was wrong.
I went simpler: highlight prefix matches in the UI using the debounced input tokens. It’s not perfect, but it’s stable and fast.
// ui/highlight.tsx
import React from "react";
import { Text } from "react-native";
export function HighlightedText({ text, query }: { text: string; query: string }) {
const tokens = query
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean);
if (!tokens.length) return {text};
// Highlight only the first token prefix. Simple. Predictable.
const t0 = tokens[0];
const lower = text.toLowerCase();
const idx = lower.indexOf(t0);
if (idx === -1) return {text};
return (
{text.slice(0, idx)}
{text.slice(idx, idx + t0.length)}
{text.slice(idx + t0.length)}
);
}
Does it highlight the second word? Nope.
But it avoids a class of bugs where the highlight logic fights the ranking logic. I’ll take “good enough” when the main goal is speed.
Results
I went from a LIKE query that routinely took 120–300ms on my Pixel 7 (and sometimes spiked to ~600ms) to an FTS query that’s usually 8–25ms for the same input.
The bigger win was UI feel.
With debounce at 120ms, typing bench press triggers 5 queries instead of 11. The search screen stays at 60fps on my iPhone 13 mini, even with the exercise list rendering 30 rows and each row showing muscle + equipment.
And offline still works. Airplane mode. Same experience.
Key takeaways
- Don’t use
%LIKE%for mobile search beyond toy datasets. - Use FTS5 with
content='table'+ triggers so you don’t duplicate data. - Sanitize user input or you’ll ship
fts5: syntax error near "-". - Debounce at ~120ms and cancel stale requests. Otherwise results flicker.
- Keep highlighting dumb. Stable beats clever.
Closing
If you’re doing offline-first search in React Native: do you rely on SQLite FTS ranking (bm25) only, or do you add your own scoring (muscle match, equipment match, recency) on top of it? What’s your exact formula?
Top comments (0)