Most habit-tracking apps fail for the same reason diets fail: they're too kind. They give you streaks that reward you for not messing up and gentle nudges when you do. Bad Habits takes the opposite approach. It weaponizes social pressure, dark humor, and financial accountability to help people quit their vices — smoking, gambling, doomscrolling, fast food, and 17 other trackable categories. If you relapse, your circle knows. If you cheat, your streak is gone. If you want to hide your damage, you'll have to spend coins to do it.
JBA Agency was tasked with building the entire product from scratch: a React Native mobile app, a production-grade backend API, and an admin dashboard — all delivered in a single continuous development cycle across 6 engineering phases. The constraint was real: zero to App Store-ready in approximately six weeks, without compromising on security, data integrity, or the complex gamification mechanics that make the app's core premise work.
"432 automated tests across 6 engineering review phases caught 130+ issues before production, including SQL injection vectors and race conditions — resulting in an 8.0/10 final engineering score." — Engineering review summary, BadHabits.app
The core tension was architectural: how do you build a social leaderboard app where users compete publicly — sharing streak data, wagering coins in duels, forming accountability circles — while enforcing strict privacy boundaries that ensure no one can see another person's individual check-in details? The answer required privacy enforcement at the database query level, not just the application layer.
Bad Habits targets people who've tried and failed at quitting something using willpower alone. The app's thesis is that shame, competition, and financial stakes are stronger motivators than achievement badges. The 21 vice categories span 7 domains: substance (smoking, alcohol, vaping), food (fast food, sugar, overeating), digital (doomscrolling, social media, gaming), gambling, shopping, transport (rideshare overuse, impulse travel), and entertainment. Users can also define custom vices with per-unit cost tracking.
The financial accountability layer is core to the experience. Every check-in calculates a real-money cost — cigarettes smoked, bets placed, coffees bought — and aggregates it into daily, weekly, and monthly dashboards. The Shame Receipt feature generates client-side receipts with equivalences: "You spent enough on cigarettes this month to buy 47 lattes." It's designed to sting. That's the point.
The mobile client is built on React Native with Expo SDK 52,
using TypeScript throughout. State management uses Zustand for
global stores — lightweight, no boilerplate, and compatible with React Native's async
rendering model. Persistent local state uses MMKV, the fastest key-value
storage available on React Native (C++ backed, synchronous reads, orders of magnitude
faster than AsyncStorage).
Animations are handled by react-native-reanimated, which runs on the UI thread
rather than the JS thread — critical for the gamification interactions (coin earn animations,
duel countdown timers, achievement unlock sequences) that need to be buttery smooth even when
the JS thread is busy. Navigation uses Expo Router with a file-based routing
structure. The UI follows a dark brutalist design language: heavy typography, high-contrast
accents, and deliberately confrontational microcopy that matches the app's irreverent tone.
Android home screen widgets update the user's current streak and daily cost every 30 minutes —
a persistent, impossible-to-ignore reminder that sits on the home screen even when the app
isn't open. This was built using the react-native-android-widget package with
a background task scheduler.
The backend is a Fastify API — chosen over Express for its schema-first
request validation (via @fastify/ajv-compiler), significantly faster throughput,
and first-class TypeScript support. Every route has a typed request/response schema, which
means malformed payloads are rejected at the framework level before they reach business logic.
This alone eliminated an entire class of injection vulnerabilities caught in early review phases.
The API exposes 20+ routes covering authentication, habit management, check-ins, the coin economy, duels, social circles, leaderboards, subscription management, and admin operations. Real-time features — duel status updates, circle notifications, streak alerts — are delivered via Server-Sent Events (SSE), which works reliably across both iOS and Android without the overhead of WebSocket connection management.
The API is deployed on Railway alongside a
React + Vite admin dashboard. Railway's environment-based deployments made
staging/production parity straightforward, and its built-in PostgreSQL and Redis add-ons
kept infrastructure management minimal during the compressed delivery timeline.
The data layer is a 19-table PostgreSQL schema managed with
Drizzle ORM. Drizzle was chosen over Prisma for its SQL-first philosophy —
generated queries are transparent and predictable, which matters when you need advisory locks,
row-level locking, and atomic transactions at the application layer without ORM magic
obscuring what's actually executing.
"19-table PostgreSQL schema with atomic transactions, advisory locks on check-ins, and row-level locking on circle joins — zero data integrity issues in production." — BadHabits.app engineering documentation
Redis handles session storage, rate limiting state, and real-time pub/sub for
SSE fan-out. The combination of PostgreSQL for durable state and Redis for ephemeral/real-time
state is a proven pattern that kept the backend stateless and horizontally scalable from day one.
The gamification layer is the product's core differentiator. It's built on four interlocking mechanics, all backed by atomic database transactions to prevent exploits:
pg_advisory_lock on check-ins to prevent
duplicate submissions in concurrent request scenarios — a TOCTOU race condition that was
explicitly caught and fixed during Phase 2 review.
Social circles are the accountability mechanism. Users can join public circles (discoverable by geolocation or category), create private circles with invite codes, or form family pairs — the only relationship type with full transaction visibility. For every other relationship, the privacy boundary is absolute.
"Privacy boundary enforced at the database query level — circle leaderboards use LEFT JOIN with COALESCE SUM to return only aggregated totals, making it architecturally impossible to leak individual check-in details." — BadHabits.app security architecture notes
This wasn't a UI-level decision. The leaderboard query aggregates check-ins into totals before they ever reach the API response serializer. There is no code path through which an API consumer could receive a list of individual transactions for another user's non-family account — the query simply doesn't produce that data. Privacy is a property of the schema and the query, not a filter applied on top of raw data.
Shame Receipts are generated entirely client-side — no server round-trip required. The
Zustand store holds the user's check-in history and per-unit cost configuration.
The receipt generation logic computes daily, weekly, and monthly totals and converts them into
relatable equivalences using a configurable equivalence table (coffee price, average meal cost,
streaming subscription price, etc.). The result is shareable as an image via the native share
sheet, generated with react-native-view-shot.
Monetization uses a dual model: a subscription tier (30-day free trial, monthly/annual plans)
and a coin economy with IAP coin packs. RevenueCat handles both — its unified
entitlement API abstracts Apple and Google billing differences, and its server-side webhook
validates purchase events before granting entitlements, preventing receipt spoofing.
Coin packs as in-app purchases were a specific security focus. The Phase 3 engineering review identified an exploitable endpoint where coin grant amounts were derived from client-provided values rather than server-side price table lookups — a classic parameter tampering vector. This was fixed by mapping product IDs to coin grant amounts entirely on the server, with the client sending only the RevenueCat product identifier.
The project was structured into 6 engineering phases, each capped by a formal review that produced a written findings report, a severity-bucketed issue list, and a phase score. The review process is JBA Agency's standard quality gate for full-stack product builds — it's what allows a compressed timeline without compressing quality.
19-table schema reviewed for normalization, constraint coverage, index strategy, and foreign key integrity. Missing cascade rules and nullable columns that should have been NOT NULL were caught and corrected before any application code was written against them.
JWT refresh token rotation, serialized token refresh to prevent race conditions on concurrent requests, advisory locks on check-ins. The TOCTOU race condition on streak calculation was identified and fixed here — a check-in submitted twice in rapid succession would have awarded a streak day twice without the advisory lock.
RevenueCat webhook validation, coin purchase endpoint parameter tamper fix, atomic coin transaction implementation, duel wagering logic. SQL injection vectors in dynamic query construction were identified and replaced with parameterized queries throughout.
Privacy boundary verification at query level, row-level locking on circle join operations (preventing simultaneous joins from exceeding circle capacity limits), invite code entropy review, and geolocation-based circle discovery radius validation.
MMKV persistence strategy, Zustand hydration order, offline queue for check-ins submitted without connectivity, Android widget update reliability, and i18n key parity across all 5 language files reviewed for missing interpolation variables.
Full 432-test suite run, EAS Build configuration review, App Store metadata, privacy manifest requirements (Apple's required reasons API declarations), and final performance profiling. Phase 6 produced the 8.0/10 final engineering score.
"The app tracks 21 vice categories across 7 domains — substance, food, digital, gambling, shopping, transport, entertainment — with custom vice support and per-unit cost calculation." — BadHabits.app product specification
The 130+ issues caught across 6 review phases weren't all critical — but several were. The security findings that would have caused the most damage in production included:
SELECT pg_advisory_lock(user_id)
at the start of the check-in transaction and releases it on commit or rollback.
Privacy was enforced structurally, not procedurally. The distinction matters: a procedural privacy control (checking permissions in a route handler) can be bypassed by a bug, a missing middleware, or a new route that forgets to apply the check. A structural control (a query that literally cannot return individual records for non-family relationships) cannot be bypassed without rewriting the query. This is the class of security architecture that distinguishes production-grade applications from prototypes.
The app supports 5 languages — English, Romanian, Spanish, German, and French — using
i18next with react-i18next bindings. Every user-facing string
is keyed; there are zero hardcoded copy strings in the component tree. The Phase 5 review
specifically audited i18n key parity across all 5 language files, checking for missing keys,
missing interpolation variables (e.g., {{count}} present in English but absent
from the German file), and plural form handling differences across languages.
"Full offline-first architecture using MMKV for local state persistence, serialized token refresh to prevent race conditions, and Android home screen widgets that update every 30 minutes." — BadHabits.app technical overview
Offline-first was a product requirement, not an afterthought. Users track habits in airports, on runs, and in areas with poor connectivity. The architecture handles this through an offline check-in queue: check-ins submitted without connectivity are stored in MMKV and synced to the API when connectivity resumes, with deduplication logic on the server to handle the case where a check-in was submitted twice (once offline, once after connectivity was restored but before the sync acknowledged). Zustand stores hydrate from MMKV on app launch, meaning the UI is fully interactive before any API call completes.
The final engineering review at the end of Phase 6 produced a structured scorecard across eight dimensions: schema integrity, API correctness, security posture, test coverage, performance, internationalization completeness, App Store compliance, and code quality. The aggregate score was 8.0/10 — with the primary deductions in test coverage gaps for edge-case duel resolution scenarios and one outstanding UI accessibility item for screen reader labels on the bribe selection modal.
The sub-200ms p95 API response time was achieved through a combination of Fastify's low
overhead, connection pooling via pg with a configured pool size matched to
Railway's instance limits, Redis-cached session lookups (no DB query on authenticated
requests), and index-optimized queries on the hot paths (check-in creation, leaderboard
fetch, coin balance read).
The instinct on compressed timelines is to treat review gates as optional — something to do after shipping. The BadHabits build demonstrates the opposite. Issues caught in Phase 2 (like the advisory lock on check-ins) would have been architecturally expensive to retrofit in Phase 5. Issues caught in Phase 3 (like the coin purchase exploit) would have been a production security incident. Each phase review paid for itself in avoided rework.
Streaks, duels, coins, and achievements are interconnected state machines. A user wins a duel, earns coins, those coins cross an achievement threshold, which triggers another coin reward, which may trigger another achievement. Each transition must be atomic, idempotent, and observable (the user needs to see what happened and why). Designing this as a set of atomic database transactions with explicit event logging — rather than application-layer state — was the right call and simplified debugging significantly.
Retrofitting privacy constraints into an existing schema is painful. The decision to enforce circle privacy at the query level — rather than through permission checks in route handlers — was made during Phase 1 schema design. By the time the leaderboard routes were implemented, the query pattern was already established. Teams building social apps should treat privacy boundaries as schema constraints, not application logic.
Managing Apple and Google billing directly involves handling receipt validation, subscription state machines, proration, refunds, and grace periods — separately, for two platforms, with different APIs and edge cases. RevenueCat unifies this behind a single entitlement API and handles the platform-specific complexity. For a 6-week build, this was a non-negotiable dependency. The webhook validation pattern (verify signature, check event type, look up user, grant or revoke entitlement atomically) is straightforward and well-documented.
If you're building a gamified mobile product, a habit-tracking app, or any full-stack React Native application, the JBA Agency engineering process — from schema design through App Store submission — is available as a managed build engagement. See the FAQ for how engagements are scoped, or explore the Fractional AI Officer model if you need ongoing technical leadership rather than a one-time build. You can also read how the same phase-gated quality process applies to SaaS product builds.