There's a specific kind of frustration that comes from using a health app that feels like it was built by engineers who've never actually used it.
The timer shows a number. The number counts down. You earn a badge. That's it.
No context about what's happening inside your body. No sense of progression. No genuine reason to keep going beyond the guilt of breaking a streak. Just a countdown and a badge that means nothing by noon.
That frustration is what made me build LifeFast — a fasting tracker that treats users like intelligent adults who deserve to understand their own biology.
This is the story of how I built it. The technical decisions, the real challenges, the things I got completely wrong, and what I'd do differently. If you're building a health app, a mobile product, or just a solo-founder side project trying to become something real — I hope this saves you some time.
The Stack (And Why I Made These Choices)
Let me get the boring part out of the way first, because the choices here shaped everything downstream.
- Frontend: React Native via Expo
- Backend: Node.js + Express + TypeScript
- Database: PostgreSQL
- State management: Redux Toolkit + RTK Query
- Build: EAS Build (Expo Application Services)
- Email: Resend
- Payments: Google Play Billing
- Push notifications: Expo Push + custom job queue
I picked React Native over Flutter primarily because I'm a JavaScript developer at heart and the ecosystem felt more familiar. Expo specifically was the right call for a solo founder — the managed workflow gets you from idea to TestFlight equivalent without spending a week configuring native build chains. The EAS Build system in particular is genuinely excellent.
PostgreSQL was a non-negotiable. I've seen enough "just use Firestore" projects hit a wall the moment they needed anything resembling a JOIN, and for a health app with users, fasts, weights, water intake, community posts, and notification jobs all needing to relate to each other — a relational database was the only sane choice.
RTK Query for data fetching was one of the better decisions I made early. The automatic caching, invalidation, and optimistic updates it provides out of the box took a huge amount of complexity off the table on the client side. Pairing it with AsyncStorage for persistence meant the app works properly offline without any heroic effort.
The Hardest Part: Modelling Time
Fasting is fundamentally about time. But time in software is famously treacherous — especially when your users are in 40+ different timezones.
The core data model for a fast looks simple:
CREATE TABLE fasts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED',
timezone VARCHAR(100) NOT NULL,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
actual_start_at TIMESTAMPTZ,
actual_end_at TIMESTAMPTZ,
duration_target_mins INT NOT NULL,
duration_actual_mins INT,
...
);
The trap I nearly fell into: storing times as local timestamps without timezone. I'd seen this mistake in other codebases and nearly made it myself anyway. TIMESTAMPTZ stores everything as UTC internally and lets PostgreSQL handle timezone conversion correctly. Combined with storing the user's timezone string ("Africa/Nairobi", "America/New_York", etc.) — this meant every query, every calculation, every scheduled notification fired at the right local time.
The server runs a cron job every 60 seconds:
setInterval(async () => {
if (cronRunning) return;
cronRunning = true;
try {
await autoStartDueFasts();
await processDue();
} catch (e: any) {
console.error("[cron] tick error:", e?.message ?? e);
} finally {
cronRunning = false;
}
}, 60_000);
autoStartDueFasts() transitions SCHEDULED fasts to ACTIVE when their start_at passes. processDue() fires push notifications from a job queue. The guard (if (cronRunning) return) prevents overlap on slow ticks — simple, and it works.
The Feature That Changed Everything: Metabolic Stages
This is where LifeFast became a different kind of fasting app.
Most fasting timers show you a percentage. 43%. 68%. 100%. These numbers don't mean anything experientially. What actually happens to your body during a fast follows a predictable, fascinating sequence — and if you show users that instead of just a countdown, everything changes.
I defined fasting stages based on published metabolic research:
| Stage | Hours | What's Happening |
|---|---|---|
| Fed State | 0–3h | Digesting, insulin elevated |
| Early Fast | 3–8h | Glycogen depleting, fat burning begins |
| Fasting State | 8–12h | Insulin low, fat oxidation accelerating |
| Fat Burning | 12–18h | Liver glycogen depleted, ketone production starting |
| Ketosis | 18–24h | Ketones measurable, metabolic switch active |
| Autophagy | 24h+ | Cellular self-cleaning in full swing |
Each stage has a name, a description, a colour, and a start/end threshold. On the client, I compute which stage the user is in at render time based on elapsed hours:
function getFastingStage(elapsedHours: number): FastingStage {
const stages = [
{ label: "Fed State", color: "#94a3b8", minHours: 0, maxHours: 3 },
{ label: "Early Fast", color: "#38bdf8", minHours: 3, maxHours: 8 },
{ label: "Fat Burning", color: "#fb923c", minHours: 8, maxHours: 18 },
{ label: "Ketosis", color: "#a78bfa", minHours: 18, maxHours: 24 },
{ label: "Autophagy", color: "#34d399", minHours: 24, maxHours: Infinity },
];
return stages.findLast((s) => elapsedHours >= s.minHours) ?? stages[0];
}
Inside the app's circular timer, the active stage label shows in its colour — a glowing cyan or violet or green dot next to the stage name. Users started messaging me: "I just hit Ketosis for the first time!" — not because I told them to care, but because seeing it named and coloured in real time made it real.
That's the difference between showing someone a number and showing them a story about their own biology.
Building the Community Layer
About two months in, I realised I was building a productivity tool when I should have been building a social one. Fasting is hard in isolation. It's dramatically easier when you're doing it alongside people who understand the challenge.
So I built a full community layer: groups, posts, comments, nested replies, likes, and a moderation system.
The schema is relatively conventional, but a few decisions were non-obvious.
Soft deletes for moderation. When a user reports a post and an admin hides it, the content isn't deleted — it's flagged:
ALTER TABLE community_posts
ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN hidden_reason TEXT,
ADD COLUMN hidden_by UUID REFERENCES users(id),
ADD COLUMN hidden_at TIMESTAMPTZ;
This means we can review decisions, reverse them, and maintain an audit trail. Hard deletes felt wrong for a moderation workflow.
Pinned posts per group. The query for listing posts handles pinning cleanly:
ORDER BY p.is_pinned DESC, p.created_at DESC
Two-token sort. Pin always wins; recency breaks ties. Simple and fast.
The notification fan-out problem. When a post in a popular group gets a comment, who gets notified? The author only. When a comment gets a reply, the parent comment's author gets notified. I built a lightweight notification_jobs table and a worker that processes it:
CREATE TABLE notification_jobs (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
kind VARCHAR(50) NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL,
payload JSONB,
status VARCHAR(20) DEFAULT 'pending',
attempts INT DEFAULT 0
);
The worker polls every 60 seconds, picks up pending jobs past their scheduled_for time, calls Expo's push notification API, and marks them as sent or failed. Dead simple, fully auditable, no external queue infrastructure needed at this scale.
The Billing Nightmare
I'm going to be honest: Google Play Billing is the worst API I have ever worked with professionally.
The documentation is spread across three different Google sites that contradict each other. The test environment behaves differently from production in ways that are not documented. The error codes are numerical and the mapping table is buried six pages deep in a section titled "Deprecated APIs".
Here's what eventually worked:
- Use the
google-play-billinglibrary on the React Native side for purchase flow - On the backend, verify every purchase token against the Google Play Developer API before granting entitlements
- Store the raw
purchase_tokenandproduct_id— you'll need both for future verification and refund handling - Handle
ITEM_ALREADY_OWNED(error code 7) gracefully — it means the user purchased on another account or device and is more common than you'd think
The verification endpoint on my backend:
router.post("/billing/google/verify", authenticate, async (req, res) => {
const { purchaseToken, productId, packageName } = req.body;
const verified = await verifyGooglePurchase(packageName, productId, purchaseToken);
if (!verified) return res.status(400).json({ success: false, error: "Invalid purchase" });
const entitlement = getEntitlementForProduct(productId);
await billingRepo.recordPurchase({ userId: req.user.id, purchaseToken, productId, entitlement });
await usersRepo.grantEntitlement(req.user.id, entitlement);
res.json({ success: true, entitlement });
});
The lesson: never grant entitlements on the client side. Always verify on the server. This is not optional.
Shipping: EAS Build and Deployment
The CI/CD pipeline runs on GitHub Actions:
- Push to
maintriggers the workflow - TypeScript compiles, then the Express app is bundled
- SSH into the production DigitalOcean Droplet
- Pull latest, rebuild Docker container, swap with zero-downtime restart
For the mobile app, EAS Build handles the actual Android build:
eas build --platform android --profile production
Then submit to Google Play via:
eas submit --platform android
EAS Submit directly uploads the AAB to the Play Store internal track. For a solo founder, this is a genuinely transformative improvement over the old "manually drag an AAB file into the Play Console" workflow.
One lesson I'd pass on: set up your production and staging environments early. I shipped several breaking changes to all users before I separated the two, and the user experience was awful. A staging environment you actually test on is not optional — it's the difference between professionalism and chaos.
What I Got Wrong
Email broadcast timing. I sent an announcement to my users about a new release. I wrote the batch-sending code to fire 50 emails in parallel. I had about 250 users at the time. The email API rate limits at 5 requests per second. You can do the math. Half my users got error responses instead of emails, and the 300ms sequential delay I needed was sitting there in the documentation the whole time.
Over-engineering notifications early. I spent a week building a sophisticated notification scheduling system before I had 50 users. The simpler version — a cron job and a jobs table — does the same job. I could have built it in a day and shipped the features users actually needed instead.
Not adding the progress percentage cap sooner. The fasting percentage can technically exceed 100% if someone continues fasting past their target window. For a while, my timer showed "127%" in a progress circle — which is both mathematically accurate and completely absurd. A one-line fix:
const percentage = Math.min(100, Math.max(0, Math.round(progressRatio * 100)));
Users notice things like this. They form impressions of quality from small details. Fix the small things before they become the big things.
The Product Is Alive
LifeFast is available now on Android.
It's free. It tracks your fasting window in real time, shows you your metabolic stage as you progress, logs your water intake, tracks your weight over time, lets you log progress photos, and connects you with a community of other fasters doing the same work.
I built this without a team, without investors, and without a roadmap handed to me by a product manager. Just a problem I cared about, a stack I understood, and enough stubbornness to keep shipping.
If you're building something similar — a health app, a behaviour-change product, anything where timing and user psychology overlap — feel free to reach out. I'd genuinely enjoy the conversation.
Top comments (0)