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.
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.
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.
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
Offline-first, as a write layer rather than a cache
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
Security enforced in the database, not the client
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
Performance, measured before and after
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
The things nobody asks for until they are missing
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
Stack, and why
// 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.



