Max Baile
Selected work
Case study · iOS appFeb — Jul 2026

Reelist

A tracking app for film and television, built local-first. Every write lands in SQLite before it goes anywhere near the network, and the sync that follows is idempotent enough to replay the same batch twice without anyone noticing.

Reelist home screen, showing the user's tracked showsReelist title detail screen, showing per-episode progress
Role
Sole developer — architecture, app, backend, release
Timeline
Feb – Jul 2026, five months
Platform
iOS 16+, TestFlight beta
Stack
Expo SDK 54 · React Native 0.81 · TypeScript · Supabase
Scale
640 beta users · 38k tracked items · 1.2M cached titles
01 / Brief

Three constraints, and one of them was not negotiable.

The product brief was ordinary — track what you watch, find what to watch next. The constraints around it were not, and they are what actually decided the architecture.

Offline is the normal case. People reach for this app in a cinema queue, on the metro, on a plane at 2% signal. An app that shows a spinner when the radio is down is not a tracking app, it is a website.

One developer, no operations. Nothing in the design is allowed to require someone awake at 3am. That rules out a queue worker, a cron box, and anything with a stateful process I have to babysit.

TMDB's rate limit is the budget. Fifty requests a second, shared across every user of the app. A naive typeahead — one request per keystroke — spends that on a hundred users and an afternoon.

All three pushed the same way: put the source of truth on the device, put the shared cache in Postgres, and treat the network as an optimisation rather than a dependency.

02 / Architecture

Three tiers, and no process I have to keep alive.

Supabase carries the parts that must be shared — identity, the per-user rows, the TMDB cache. Everything else runs on the device, and every server-side component is a function that starts on a request and ends with the response.

DeviceExpo SDK 54 · iOS 16+
UIReact Native · FlashList · Reanimated
Query layerTanStack Query, reading SQLite
SQLiteexpo-sqlite · the source of truth
Outboxdurable queue · ULID idempotency keys
HTTPS · batched replay, 50 mutations per request
EdgeSupabase Edge Functions · Deno
/syncreplay outbox, pull delta since cursor
/searchTMDB proxy · stale-while-revalidate
/postersigned URLs, 30-day expiry
Postgres wire protocol · service role, single-purpose
DataSupabase Postgres 15
watchlist · episodes · ratingsrow level security, per user
search_cachepg_trgm · 24h TTL
TMDB APIexternal · 50 req/s, shared

The device is authoritative for user data. SQLite holds the watchlist; Postgres holds a replica that other devices can read. Inverting that — server first, cache second — would have meant a loading state on every screen and a conflict story anyway, because the writes still happen offline.

Postgres is authoritative for TMDB data. It is shared, it is identical for every user, and it is the one thing a rate limit makes expensive. Caching it centrally means the six-hundredth user to search for a title pays nothing for it.

The edge functions are deliberately thin. Three of them, none longer than 150 lines, none holding state between requests. The business rules that matter — conflict resolution, access control — live in SQL, where they are enforced for every caller rather than for the callers that remembered.

03 / Engineering

Four problems worth the words.

The rest of the build was ordinary product work. These four were not, and each one changed a number in the table further down.

A

Writes that survive the tunnel

Problem. Early builds wrote optimistically to local state and fired the request in the background. On a flaky connection the request failed, the local state kept the change, and the two silently diverged. In a fortnight of internal testing roughly one write in fourteen was lost this way — and the user was never told.

Approach. Every mutation is persisted to an SQLite outbox inside the same transaction that applies it locally, keyed by a client-generated ULID. A flush drains the queue in batches of fifty; anything that fails stays queued with an incremented attempt count and exponential backoff. The ULID doubles as an idempotency key, so replaying a batch that already landed is a no-op.

Result. Twenty thousand synthetic mutations replayed against a proxy that drops one request in three: zero lost writes, zero duplicates. The app has no “syncing” state to render because there is nothing for the user to wait on.

app/db/outbox.tsTypeScript
// db/outbox.tsexport type Mutation = {  id: string; // client-generated ULID — also the idempotency key  table: "watchlist" | "episodes" | "ratings";  op: "upsert" | "delete";  payload: Record<string, unknown>;  updatedAt: number; // device clock, reconciled server-side}; // Every write lands here first. The UI reads from SQLite and never from the// network, so the optimistic state *is* the state until a flush says otherwise// — which means there is no "pending" branch to render anywhere in the app.export async function enqueue(db: SQLiteDatabase, mutation: Mutation) {  await db.withTransactionAsync(async () => {    await applyLocally(db, mutation);    await db.runAsync(      `INSERT INTO outbox (id, table_name, op, payload, updated_at, attempts)       VALUES (?, ?, ?, ?, ?, 0)       ON CONFLICT (id) DO UPDATE SET         payload    = excluded.payload,         updated_at = excluded.updated_at`,      [        mutation.id,        mutation.table,        mutation.op,        JSON.stringify(mutation.payload),        mutation.updatedAt,      ],    );  });}
supabase/functions/sync/replay.sqlSQL
-- The replay is unordered and may repeat: a retry has to be a no-op, and a-- device that has been in a tunnel for an hour must not overwrite a newer-- write from the user's other phone. The guard in the WHERE clause is the-- whole conflict policy — last write wins, decided by the row, not the request.insert into watchlist (id, user_id, tmdb_id, status, updated_at)values ($1, auth.uid(), $2, $3, $4)on conflict (id) do update   set status     = excluded.status,       updated_at = excluded.updated_at where watchlist.updated_at < excluded.updated_at;
B

Search that answers inside a frame

Problem. Typeahead against TMDB meant a round trip per keystroke: 620 ms at p95 from a European phone, and a thousand outbound calls per thousand in-app searches. At a few hundred users that is the whole rate limit, and the limit is shared — exceeding it breaks search for everyone at once.

Approach. An edge function in front of TMDB, backed by a search_cache table with a 24-hour TTL and a trigram index. Responses are served stale-while-revalidate, so a warm query never waits on the upstream. Because a shorter query is usually a prefix of one already answered, similarity matching absorbs most of the keystrokes before they become requests. The client debounces at 180 ms and cancels in-flight queries on the way.

Result. p95 fell to 90 ms, and outbound TMDB calls to 63 per thousand searches — a 94% reduction, with headroom to grow the beta by an order of magnitude before the limit is in view again.

supabase/functions/search/index.tsTypeScript
// supabase/functions/search/index.tsconst TTL_MS = 24 * 60 * 60 * 1000; // title metadata moves slowly; a day is safe Deno.serve(async (request) => {  const q = new URL(request.url).searchParams.get("q")?.trim().toLowerCase();  if (!q || q.length < 2) return json({ results: [] });   const cached = await readCache(q);   // Stale-while-revalidate: answer from Postgres now, refresh behind the  // response. A user never waits on TMDB for a query someone already typed.  if (cached) {    if (Date.now() - Date.parse(cached.fetched_at) > TTL_MS) {      EdgeRuntime.waitUntil(refresh(q));    }    return json({ results: cached.results, source: "cache" });  }   return json({ results: await refresh(q), source: "tmdb" });});
supabase/migrations/0007_search_cache.sqlSQL
create extension if not exists pg_trgm;create index search_cache_query_trgm on search_cache using gin (query gin_trgm_ops); -- Typeahead rarely asks a question we have not already answered: "interste"-- is a prefix of "interstellar", and the cached result set for the longer-- query is a superset of the right answer. Matching on similarity turns most-- keystrokes into an index scan instead of an outbound request.select results  from search_cache where query % $1    or query like $1 || '%' order by similarity(query, $1) desc limit 1;
C

A 900-poster list at 60fps

Problem. The watchlist is one list with every tracked title in it — several hundred rows for an ordinary user, nine hundred for the heaviest tester. On a stock FlatList the JS thread sat at 42 fps during a fast scroll and left visible blank cells: 37 per thousand rows, measured by screenshotting mid-fling in an instrumented build.

Approach. FlashList with a stable getItemType, so a title row never recycles into a section header — that mismatch was the actual source of the blanks, not the recycling itself. Rows memoised on identity plus updatedAt, poster URLs resolved once at query time rather than per render, and the draw distance widened to give the image decoder a screen of lead time.

Result. 59 fps sustained on an iPhone 12, no blank cells across a thousand rows of automated fling, and the same numbers on a three-year-old iPhone SE, which is the device the brief actually cared about.

app/features/watchlist/list.tsxTSX
// Two changes did almost all of the work. A stable getItemType stops a poster// row from recycling into a section header — the mismatch was what produced// the blank cells. A wider drawDistance gives the decoder a full screen of// lead time, so posters are ready before they scroll into view.<FlashList  data={rows}  renderItem={renderRow}  keyExtractor={(row) => row.id}  getItemType={(row) => row.kind} // "section" | "title"  estimatedItemSize={116}  drawDistance={600}  removeClippedSubviews/>; // updatedAt, not a deep compare: the row is the only thing that can change it,// and a bumped timestamp is exactly the signal that it did.const TitleRow = memo(Row, (a, b) =>  a.item.id === b.item.id && a.item.updatedAt === b.item.updatedAt,);
D

Multi-device sync without a backend to trust

Problem. With no server of my own, the client talks to Postgres almost directly. Access control could not be a check in application code, because the application code is the thing running on a device I do not control.

Approach. Row level security on every user table, written as separate read and write policies, with the anon key as the only credential the app ever holds. The service role exists in exactly one place — the sync function — and is scoped to the replay statement. Policies ship as migrations and are tested as a signed-in user against a real Postgres in CI.

Result. A leak that reached staging once, in week three, and has not been possible since the policy tests landed: they fail the build, not a review.

supabase/migrations/0011_watchlist_rls.sqlSQL
alter table watchlist enable row level security; -- Two policies rather than one "for all": the read path is the hot one and-- deserves its own index-friendly predicate, and a mistake in the write policy-- then cannot silently widen what a user can read.create policy watchlist_select on watchlist  for select using (user_id = auth.uid()); create policy watchlist_insert on watchlist  for insert with check (user_id = auth.uid()); create policy watchlist_update on watchlist  for update using (user_id = auth.uid())          with check (user_id = auth.uid());
04 / Proof

What actually changed, and how it was measured.

Before is the last build prior to the work described above; after is the build in the hands of the beta. Both were measured on the same devices, on the same fixture data, on a throttled connection.

MetricBeforeAfterMeasured with
Search latency, p95 (cold query)620 ms90 msSentry
TMDB calls per 1,000 in-app searches1,00063Edge logs
Watchlist scroll, JS thread42 fps59 fpsPerf monitor
Blank cells per 1,000 rows scrolled370Detox
Cold start to interactive, iPhone 123.1 s1.4 sSentry
Writes lost to a dropped connection1 in 140 in 20,000Replay harness
Crash-free sessions97.2 %99.4 %Sentry

Latency and crash figures come from Sentry over a rolling 30 days of real sessions, not from a laboratory run. Frame rates and blank cells come from a scripted Detox fling over a 900-row fixture, repeated twenty times per build. The write-loss figure comes from a replay harness that drives the outbox through a proxy dropping one request in three — the only one of these numbers produced by a test I wrote specifically to make the app look bad.

05 / Delivery

Tested where it was worth testing.

A solo project earns its test suite in the places a regression would be silent. Rendering is not one of those places; sync, policies and migrations are.

214 unit tests under Jest and React Native Testing Library, weighted heavily toward the outbox, the conflict rules, and the date and episode-numbering arithmetic that TMDB makes surprisingly hostile. 87% line coverage on lib/, far less on screens, deliberately.

Integration tests against a real Postgres, started by supabase start in CI, with every migration applied from zero on each run. Policies are asserted as a signed-in user — the distinction that matters most, and the easiest one to get wrong.

One Detox smoke path, end to end: sign in, search, add a title, kill the network, add another, restore, and assert both rows on a second signed-in client. It is slow and it has caught three real bugs.

EAS Build and EAS Update on a beta channel: JavaScript changes reach testers over the air in minutes, and a native build only runs when a native module actually changes. Sentry source maps upload from CI, so a stack trace from a tester is readable rather than minified.

supabase/tests/watchlist.policies.test.tsTypeScript
// Asserted as a signed-in user against a real Postgres, never as service_role// — a policy test that runs with the service key proves nothing at all.it("does not leak another user's watchlist", async () => {  const mallory = await signInAs("mallory@example.test");   const { data, error } = await mallory    .from("watchlist")    .select("id")    .eq("user_id", alice.id);   expect(error).toBeNull(); // RLS filters rows, it does not raise  expect(data).toEqual([]); // ...so the empty result is the assertion});
06 / Retrospective

Three things I would do differently.

Written while it is still inconvenient to admit.

The outbox should have been the only writer from day one. For three weeks an in-memory store sat alongside it as a “fast path”, and every stale-read bug in that period traced back to the two disagreeing. Deleting it took an afternoon and removed a category of bug entirely.

The Postgres test harness should have preceded the first policy. It was built after a leak reached staging. Everything it caught afterwards, it would have caught before — the ordering was the only mistake.

Last-write-wins has a shelf life. It is honest for rows with exactly one owner, which is every row in the app today. Shared lists are on the roadmap, and the moment two people can edit the same row, a timestamp comparison stops being a conflict policy and starts being a way to lose someone’s edit. That is a rewrite toward CRDTs, and I would rather schedule it than discover it.

End of case study

Want the same written about your product?