Building a production community platform across mobile, realtime systems, and on-device AI.
Cross-platform community app on Google Play: feeds, moments, realtime chat, events, leaderboards, and an offline interest classifier.
- Lead mobile engineer
- 2025 — Present
- Lead on the mobile client, working alongside another mobile engineer.
- iOS and Android from one codebase · published on Google Play
- React Native
- Expo
- TypeScript
- Redux Toolkit
- TanStack Query
- Socket.IO
- Firebase Cloud Messaging
- Notifee
- Sentry
- Mixpanel
- EAS Update
- Jest
Context
Whistler is a community social platform. People join communities around shared interests, post to community feeds, share short “moments”, chat in real time, RSVP to events, and move up community leaderboards and tiers. Community owners get insight dashboards on growth, retention and engagement.
The mobile app is the main product surface. It runs on iOS and Android from one React Native codebase and is published on the Google Play Store.
My responsibility
I owned
- Mobile client architecture and day-to-day engineering as lead engineer
- Realtime chat client: Socket.IO connection lifecycle, reconnection and app-state handling
- Push notifications (FCM delivery, Notifee display), deep linking, theming and dark mode
- Social sign-in with Google, Apple and Facebook
- The on-device interest classifier: training data, the training and export pipeline, and pure-JavaScript inference
- Sentry crash reporting and Mixpanel / Firebase Analytics instrumentation
- Android release builds (Gradle) and EAS over-the-air update channels
- A load-test harness (Node and k6) that replays the onboarding API sequence for up to 1,000 simultaneous accounts
I collaborated on
- Screens and features built alongside another mobile engineer
- API contracts with the backend, which is maintained outside the mobile codebase
- Feature scope and priorities with the people running the product
The challenge
A community app is judged on whether it feels alive: messages arrive immediately, a notification opens the right screen, and the feed reflects what just happened. On a phone, all of that has to survive dropped connections, the app being backgrounded mid-session, and operating-system limits on background work.
The product also needed to understand what people post about — mapping free text to the platform’s 30 interest categories — without sending every post to a third-party AI service.
Constraints
- Unreliable networks
- Mobile connections drop and resume constantly. The client can’t assume the socket is connected, and anything emitted while it isn’t can be lost.
- Two platforms, three identity providers
- iOS and Android differ on notifications, background execution and sign-in rules. Apple requires Sign in with Apple when other social logins are offered.
- No native ML runtime
- The classifier had to run in plain JavaScript. That rules out native inference libraries, but means a retrained model ships in an EAS update instead of a store release.
- Bundle size
- The model ships inside the JavaScript bundle, so every user pays for its size on install and on every update that changes it.
- OTA boundary
- Over-the-air updates can change JavaScript and assets only. Anything that touches native code needs a full Gradle build and store review.
Architecture
- Community
- Chats
- Moments
- Events
- Profile
- Insights
- TanStack Query — server state
- Redux Toolkit — session & UI state
- Socket context — realtime
- Axios API client + interceptors
- Socket.IO client
- Intent classifier (on-device)
- Pending-event queue (AsyncStorage)
- FCM + Notifee
- Deep links
- Google / Apple / Facebook sign-in
- Sentry
- Mixpanel + Firebase Analytics
- REST API
- Socket.IO server
- Server state and client state are kept apart. TanStack Query owns anything fetched from the API — caching, refetching and invalidation. Redux Toolkit holds session and UI state that the server doesn’t own.
- Realtime lives in a single Socket context, so the connection lifecycle is managed in one place rather than per screen.
- The API client centralises auth headers and response handling, including 401s, so individual screens don’t each re-implement session expiry.
Exploration
- Where should classification run?
- A hosted LLM call per post would handle nuance best, but adds per-request cost, latency, a network dependency, and sends user text to a third party. Keyword rules are free and transparent but break on implicit language — “Gate 47, three-hour delay, I don’t care, I’m going” is about travel without using the word. A small supervised model sits between the two: it learns from examples and runs offline.
- How big can the model be?
- The first export stored each word vector as its own base64 string at 100 dimensions — about 5.5 MB. Repacking to one int8 blob at 50 dimensions with a single global scale brought the shipped module to about 768 KB.
Key decisions
01On-device fastText classifier instead of an LLM API
- Map free-text posts to one of 30 interest categories.
- Call a hosted LLM for every post
- Keyword and rule matching
- Train a small supervised model and run it on the device
- The LLM handles nuance but costs money per post, adds latency and a network dependency, and sends user text off-device. Rules are cheap but brittle. An on-device model is private and offline, but its quality is bounded by its training data and it needs a retraining pipeline.
- Train a fastText classifier, export the weights, and run inference in pure JavaScript with no native modules.
- This is a narrow, fixed-label task. It doesn’t need a generative model, and keeping user text on the device matters for a social app.
- Inference runs offline. The model ships in the JS bundle (~768 KB) and can be replaced with an over-the-air update. Accuracy is covered in the evaluation below.
02Disconnect the socket in the background; queue what would be lost
- A background socket wastes battery and gets killed by the OS anyway, and view events emitted while disconnected silently disappear.
- Keep the socket open and let the OS decide
- Disconnect on background and drop pending events
- Disconnect on background and persist pending events to disk
- Holding the socket open is unpredictable across platforms. Dropping events loses analytics. Persisting events costs storage and needs a bound, because an unbounded on-device queue is its own liability.
- Tie the socket to AppState: disconnect on background, reconnect on foreground, with Socket.IO reconnection (5 attempts, 1 s backoff growing to 10 s). View events are written to an AsyncStorage queue capped at 50 and flushed once the socket reports connected.
- Connection state follows what the user is actually doing, and the events most likely to be lost — those fired as the app is backgrounded — survive the app being killed.
- Reconnection and flushing happen in one place. Only one flush runs at a time, and it is a no-op while disconnected. Both paths have unit tests.
03Two over-the-air channels for staged releases
- JavaScript fixes shouldn’t wait for store review, but an untested update shouldn’t reach production users either.
- Ship every change through the Play Store
- One OTA channel for everything
- Separate development and production OTA channels
- Store-only releases are slow. A single channel means testing on production. Two channels add process, and changes that touch native code still need a store build.
- EAS Update with a development channel (on development-client builds) and a production channel, plus Gradle app-bundle builds for native changes.
- Changes reach a development build first. Production gets the same update only after it has been exercised there.
- JavaScript-only fixes, including classifier retrains, reach production without a store release. Native changes still go through Gradle and Play Store review.
An AI classifier that runs entirely on-device
The classifier maps a post to one of 30 interest categories without a network request. Training happens offline in Python; the app ships only the exported weights and about 350 lines of TypeScript inference code.
- 01User textA post or bio as typed
- 02PreprocessingLowercase, strip punctuation, keep tokens of 2+ characters
- 03FastText embeddings9,989-word vocabulary × 50 dimensions, int8
- 04Feature vectorAverage of matched word vectors
- 05Softmax classifier30 × 50 output layer
- 0630 interest categoriesTop class if p ≥ 0.35, otherwise uncategorised
function classify(text: string): Result {
const tokens = tokenise(text); // lowercase, strip, length ≥ 2
if (tokens.length < 2) return UNCATEGORISED;
const sentence = averageEmbeddings(tokens); // int8 rows × global scale
if (!sentence) return UNCATEGORISED; // no known words
const probs = softmax(CLASSES.map((c) => dot(c.vector, sentence)));
const top = argmax(probs);
return probs[top] >= 0.35
? { category: CLASSES[top].name, confidence: probs[top] }
: { category: null, confidence: probs[top] };
}Why this architecture
- Offline: classification works with no connection.
- Private: post text never leaves the device for this feature.
- Cheap: no per-request API cost. Inference is a lookup, an average and a 30 × 50 matrix product.
- Updatable: pure JavaScript, so a retrained model ships over the air.
Evaluation methodology
The training script reported 99.0% precision@1 on held-out data. Re-running it showed why: each hand-written sentence was augmented into three near-copies before a random 85/15 split, so 2,199 of the 2,238 validation lines had a twin in training.
Splitting by sentence first gives 60–64% precision@1 across three random splits, against a 3.3% chance baseline. The data is 4,962 unique hand-written sentences across 30 categories. No real user posts have been evaluated yet.
- Precision@1 on sentences never seen in training
- 60–64%
- Three random splits grouped by sentence. Chance is 3.3%.
- Precision@1 as originally reported
- 99.0%
- Inflated: near-copies of 98% of validation lines were in training.
- Shipped model size
- 768 KB
- int8 weights, down from ~5.5 MB in the first export format.
Implementation
- Authentication
- Google, Apple and Facebook sign-in feed one session. The Axios client attaches the token and handles 401 responses centrally — and only signals session expiry when a token was actually set, so unauthenticated calls don’t trigger a false logout.
- Notifications and deep links
- Firebase Cloud Messaging delivers pushes and Notifee handles on-device display. Deep links route a tap to the specific screen it refers to.
- Realtime messages
- Incoming socket payloads are checked for required fields before they enter state. Malformed messages are logged and dropped rather than rendered.
- Theming
- An app-wide theme provider supplies colour tokens, so dark mode is a token swap rather than per-screen overrides.
- Tests
- Jest covers the pending-event queue, socket analytics, view tracking, and the community-insight dashboards and their tabs — 14 test files at the time of writing.
Production
- Crash reporting
- Sentry captures crashes and errors from production builds.
- Analytics
- Mixpanel and Firebase Analytics track product usage. Socket-level view events go through the persisted queue described above.
- Releases
- Gradle app bundles for the Play Store, with EAS-managed version auto-increment. JavaScript changes go through the development channel, then the production channel.
- Load testing
- A Node harness replays the full onboarding sequence — register, username check, profile update, image upload, interest selection — for up to 1,000 accounts at once. k6 scenarios cover feed and moments traffic.
Screens

MobileChats· 1 / 5
Realtime chat list. Personal data redacted.
Outcome
Whistler is live on the Google Play Store. The app runs realtime chat, push notifications, social sign-in and on-device classification in production, with crash reporting and analytics in place.
Usage figures are not published here: they belong to the product, and I won’t quote numbers I can’t show the source for.
- Precision@1 on sentences never seen in training
- 60–64%
- Three random splits grouped by sentence. Chance is 3.3%.
- Precision@1 as originally reported
- 99.0%
- Inflated: near-copies of 98% of validation lines were in training.
- Shipped model size
- 768 KB
- int8 weights, down from ~5.5 MB in the first export format.
- Active users
- —
- Impact metric unavailable.
Lessons
A validation score is only as good as the split behind it. I augmented each sentence into three near-copies before splitting, so 2,199 of 2,238 validation lines had a twin in training. The 99% measured memorisation. Splitting by source sentence first gives 60–64%: useful, but a different claim. I now split before augmenting, and report the method next to the number.
Most realtime work on mobile is lifecycle work. The hard part wasn’t sending messages — it was deciding what the connection should do when the app is backgrounded, killed or offline, and making sure nothing important disappears in between.
A confidence threshold is a product decision. Returning “uncategorised” below 0.35 is better than confidently putting a post in the wrong community.
Next iteration
- Build an evaluation set from real, anonymised posts and report per-class precision, recall and a confusion matrix — the current numbers come from hand-written sentences.
- Evaluate the quantised JavaScript model directly. Today’s scores are from the float model before int8 export.
- Tune the 0.35 threshold against a precision target on real data instead of choosing it by hand.
- Extend automated tests to reconnection and notification routing, where failures are hardest to reproduce by hand.



