Book a call
← Work
// case_study
CONSUMER PRODUCT · HEALTH & FITNESSin active development

One product, five domains, three platforms

Built for 40percent. Habits, workouts, nutrition, calendar and social in one codebase that runs on iOS, Android and the web — and keeps working with no connection.

Engagement
Full product build
Platforms
iOS · Android · Web
Stack
React Native · Expo · Supabase
Status
In active development
The 40percent web dashboard: daily progress ring, workout and calorie stats, today's goals, macro breakdown and a friends activity feed
The web application. 40percent runs the same codebase on iOS, Android and the browser.
3
platforms from one codebase
~75k
lines of production TypeScript
74
database migrations
0.6s
largest contentful paint

The problem

Lifestyle tracking is fragmented. People run one app for training, another for food, another for habits, and a calendar somewhere else — none of which know about each other, and most of which stop working the moment the signal drops. 40percent needed all of it in one product, on every platform its users owned, built by a team small enough that a second codebase per platform was never an option.

The product also had a point of view. Its name is its thesis: if you only have 40% to give and you give 40%, you gave everything you had. The logo is a progress ring stopped at 40%. That set the bar for how forgiving the product had to feel on a bad day.

What we built

One React Native and Expo codebase targeting iOS, Android and web, on a Supabase Postgres backend with row-level security on every table. Five product domains behind a shared design system, an offline-first write layer that queues and replays every mutation, and a subscription model whose entitlements are enforced server-side in the database rather than trusted from the client.

Five domains, one product

Each would be a credible standalone app. The problem was making them share a design system, a data model and a sync layer.

Habits
Custom schedules, sub-steps, a 16-week consistency heatmap, local reminders, and habits shared with friends.
Workouts
A 1,325-exercise library, reusable templates, live logging, personal-record detection, and a rest timer on the iOS Lock Screen.
Nutrition
Barcode scanning, a macro and calorie ring, meal builder, hydration, and targets from Mifflin-St Jeor or Katch-McArdle.
Calendar
Month, week and day views, recurring series with month-end clamping, and routines that generate their own days.
Social
A keyset-paginated feed, friend graph with presence, live challenge leaderboards, and eight classes of push notification.
The workouts screen: a 12-week volume chart, saved workout templates and a personal records list
Twelve weeks of volume, saved sessions, and personal records surfaced automatically.
The nutrition screen: a calories-remaining ring, protein, carb and fat targets, and the day's meal list
Food logged by barcode or by hand, against the day's calorie and macro targets.
The social feed: a shared workout post showing duration, volume and per-exercise set, rep and average-weight detail
Finished sessions post as a frozen snapshot, with per-exercise detail friends could not otherwise read.

Where it landed

Shipped to Google Play and TestFlight with the web app live, across 34 user-facing releases. The whole surface — including analytics and the exercise library — works offline, accessibility passes WCAG 2.1 AA, and a first-load regression was taken from 11.3s to 0.6s.

On Google Play and TestFlight, web app live, App Store listing in progress. We are not going to claim user numbers for a product that has not finished launching — what is demonstrable here is the engineering.

// engineering_detail

The rest of this page is for engineers. If you are evaluating whether we can build something to a standard, this is the part that answers it.

Offline-first, as a write layer rather than a cache

A durable mutation queue, 46 job handlers, and replaying without duplicating

People log sets in gym basements and scan barcodes in supermarket aisles — two of the places phone signal is worst. Offline could not be a degraded mode. Most apps that claim offline support mean they cache reads; here every write path — habits, workouts, nutrition, calendar, social, challenges, profile and settings — goes through a durable mutation queue in device storage, replayed on reconnect. Around 46 job handlers are registered across the feature modules. Nothing fails because the network is gone; it queues.

The hard part is replaying honestly. Creates carry client-generated identifiers, so a replayed insert that collides with an existing row counts as success. Inverse pairs cancel before they reach the server — a like followed by an unlike is no request at all. Deleting a post that never synced purges the comments and likes queued against it. Challenge increments sum locally, because the underlying call applies a delta and is not safe to repeat.

Read-side equivalent: the query cache persists on data-changing events only, with the exercise library and workout statistics in their own storage keys. After one online launch, charts, personal records and the full exercise picker work with no connection.

A crash we had to earn our way out of

Early builds terminated on iOS under heavy interaction. The crashes surfaced as watchdog terminations, which tell you almost nothing on their own. Correlating them with touch-burst breadcrumbs pointed at cache invalidation: a global invalidate was firing on every queue drain, re-rendering the whole tree.

The fix: each queued job declares which query namespaces it touches, invalidations coalesce into a single pass, and static keys are never touched. Same correctness, a fraction of the work, and the terminations stopped.

Security enforced in the database, not the client

Row-level security on ~52 tables; entitlements derived identically client and server

74 migrations, roughly 52 tables and around 60 Postgres functions, with row-level security on every table. Several migrations exist purely to fix policy recursion and tighten the trust boundary — security-definer helpers to break recursive policies, escaping on user search, and revoked grants on functions that did not need to be callable.

The part worth stealing: subscription entitlements derive from two timestamp columns on the user row, and the same derivation runs in the client and inside the row-level security policies. Free-tier limits are a server-side backstop, not a client-side courtesy — and entitlement checks work offline, because the client computes from data it already holds using identical logic.

Friends cannot read each other's workout sessions under these policies, so a shared post bakes a validated snapshot of its per-exercise detail at post time rather than referencing rows the reader cannot see. Editing or deleting the original can never break a link someone already received.

Performance, measured before and after

11.3s to 0.6s, from four specific changes

The web build initially scored 54 on mobile PageSpeed, with a largest contentful paint of 11.3 seconds. It now paints at 0.6 seconds locally — from four specific changes, not general tidying:

  • A splash screen in pure CSS, hidden by a sibling selector once the app root fills — no JavaScript, because the content security policy forbids inline scripts.
  • Font stylesheets fetched at build time and inlined, so no font request blocks first render.
  • The payments SDK — 767 KB on its own — moved behind a dynamic import.
  • Session-replay modules stubbed out of the web bundle entirely.

Entry bundle fell from 6.93 MB to 6.00 MB. On Android, release builds are minified and restricted to ARM ABIs, taking the shipped bundle from 108.6 MB to 68.2 MB. On device, long lists are windowed manually, per-keystroke state writes are debounced, and so are connectivity changes, so a signal blip cannot trigger a refetch storm.

The things nobody asks for until they are missing

Accessibility, kill switches that fail open, release engineering, 48 test files

Accessibility

A full WCAG 2.1 AA pass with documented contrast tables for both themes. Animations gate on the reduce-motion preference, dialogs carry real semantics with focus traps on web, and icon-only buttons require labels by convention.

Remote kill switches that fail open

Six risky subsystems — barcode scanning, third-party import, realtime, two sign-in providers and the CAPTCHA gate — sit behind flags stored inverted, as kill switches, because the analytics SDK resolves an unknown flag to false. A missing key, a deleted flag, or no network all resolve to “feature works”.

Release engineering

Over-the-air updates pinned to a runtime version policy, with debug identifiers injected through the bundler so those builds still symbolicate in crash reports. 34 user-facing releases, each surfaced in-app to users who have not seen it.

Testing and safety nets

48 unit test files over the pure domain logic — streaks, one-rep-max estimation, recurring series, reminder planning, entitlements, offline coalescing — plus three end-to-end browser journeys and two load scripts. Development-only shape assertions at every query edge surface schema drift immediately.

Platform depth where it counts

The rest timer runs as an iOS Live Activity on the Lock Screen and in the Dynamic Island, in an isolated runtime with no access to app state — which required an operation queue and an orphan sweep on launch. Deep links are verified on both stores.

Stack, and why

Six choices and the reason for each

React Native · Expo
One codebase compiled to iOS and Android and rendered on the web.
TypeScript (strict)
Domain types kept framework-agnostic, with runtime validation at every boundary.
Supabase · PostgreSQL
Database, auth, storage, realtime and edge functions from one provider, with row-level security doing access control.
TanStack Query
Server-state cache, extended with custom persistence so the app opens fully populated with no network.
Zod
Schema validation on API responses, shared payloads and stored snapshots.
Sentry · PostHog
Crash reporting and product analytics, used together to diagnose the performance issues above.

// common questions

Can you work with our existing codebase?

Most of our work is in systems that already exist. We start by reading the code and the data model rather than proposing a rewrite, because a rewrite is usually the most expensive answer to a question nobody has asked properly yet.

What if our stack is not one you list?

The languages and tools we list are the ones we have shipped production work in, not a limit. The judgement that matters — data modelling, failure handling, release engineering, knowing what to leave alone — moves between stacks. If yours is unusual, ask.

How do you handle accessibility?

As a requirement, audited rather than assumed. The build in this case study was taken to WCAG 2.1 AA and tested with real screen readers, not just an automated checker.

Have something with this shape?

If you are weighing up a build like this — several systems that need to behave as one product, on more than one platform — a call is the fastest way to find out whether we are a fit.

Book a quick callBack to work