MotoTiming: Building a Real-Time Motorsport Companion App
How I designed and developed a resilient live timing platform, integrated several external APIs, introduced in-app subscriptions, and created a practical testing workflow for race-day conditions.
MotoTiming: building a real-time motorsport companion app
MotoTiming is a cross-platform motorsport companion app built to make a race weekend easier to follow. It combines live timing, schedules, starting grids, results, rider profiles, lap charts, race analysis, favourite-rider tools and configurable notifications in one experience across iOS, Android and the web.
The interesting part of the project was not simply displaying timing data. Live sport is an unusually demanding environment: source data changes every second, traffic is concentrated into short periods, upstream APIs may be slow or inconsistent, and users expect the application to remain accurate while a session is in progress. At the same time, the native app needed purchases, restore behaviour, push notifications and a fair migration path for customers who had previously bought the app.
I therefore developed MotoTiming as a complete platform rather than a standalone mobile client. A Node.js ingestion service collects the live feed, MySQL stores and normalises it, a Next.js backend exposes a controlled API, and an Expo/React Native app consumes that API on iPhone, iPad and Android. This architecture gave me one place to deal with caching, data correction, upstream failures and load, while keeping the native client focused on presenting a fast and clear race experience.
The brief
The goal was to turn several different types of motorsport data into a coherent product:
- second-by-second live timing and rider status;
- event calendars and session schedules;
- grids, classifications and championship standings;
- rider, circuit and image metadata;
- processed lap charts and race analysis;
- browser and native push notifications;
- premium features sold through an annual in-app subscription.
These data sources did not all behave in the same way. Live timing had to be polled frequently, while schedules and historical results changed much less often. Images were expensive to request repeatedly. Results data could be processed into richer features, but only after a session had finished. The first major design decision was consequently to avoid treating every external API call as an ordinary request from the phone.
Architecture
The finished system is split into four main parts.
- The ingestion service polls the upstream live timing gateway. During an active session it can poll every second; while idle it backs off to a much slower interval. It validates and transforms each payload before upserting the current session and rider state into MySQL.
- MySQL holds the current timing state, schedules, cached API responses, media assets, push tokens, notification history, access preferences, raw captures and processed lap-chart data.
- The Next.js API is the boundary between MotoTiming and its upstream providers. It serves live timing from MotoTiming's own database, proxies historical endpoints, handles caching and stale fallbacks, and provides app-specific endpoints for analytics, rider details and notifications.
- The Expo/React Native app provides the native interface and device integrations. It uses React Navigation, local persistent preferences, Expo notifications and calendar support, RevenueCat purchases, and optional PostHog analytics.
The native app deliberately never calls the live upstream service directly. If every installed app polled an external timing gateway once per second, load would grow with the audience and I would have no reliable way to absorb an outage or correct an unusual response. In MotoTiming, one ingestor performs the shared upstream work and all clients read a stable internal representation.
Integrating live timing data
The live feed was the most time-sensitive integration. Its payload contains a session header alongside a changing list of riders, positions, gaps, laps, pit status and timing information. The ingestor converts this into a relational representation: a current session record and an ordered set of current rider records.
Polling speed changes according to state. A clearly running session uses the fast polling interval; a finished or idle feed uses a longer interval. This matters because a fixed one-second poll throughout the week would waste requests and database writes when nothing is happening.
I also added a schedule-aware write window. Live payloads are written shortly before a scheduled session, throughout genuine activity, and for a controlled period after the session finishes. Stale data from an old session cannot restart the write window after a service restart. This reduced unnecessary storage while retaining the data needed for live views, analysis and development.
The public /api/timing endpoint does not contact the upstream timing API. It reconstructs the response from the latest session and rider rows in MySQL. A short in-process cache and an in-flight promise coalesce simultaneous refreshes: if hundreds of users poll at almost the same moment, they share one database refresh per application instance rather than creating hundreds of identical queries. Small timing jitter in the clients further reduces request spikes.
Resilience was just as important as speed. The endpoint can serve a recent in-memory result during a brief database problem and can fall back to the latest stored timing snapshot. This means a temporary wobble does not immediately turn the live screen into an error page. Response headers identify whether data came from a fresh read, memory or a stale fallback, which makes production behaviour easier to diagnose.
Schedule, results and “Pulse” API integration
Schedule and results data use a different upstream API and a different caching policy. MotoTiming requests seasons, events, categories and sessions, then combines them into an app-specific schedule model. Independent requests are made in parallel where possible—for example, events and categories can load together, as can the session lists for MotoGP, Moto2 and Moto3.
Time zones were a subtle part of this work. A session time represents the local time at the circuit, but the user expects an accurate time wherever they are. The backend associates events with their time zone, converts circuit-local session values into UTC, and sends an unambiguous timestamp to the client. The app can then format it in the device's local time without guessing.
Historical data is exposed through internal /api/pulse and results routes. Those routes preserve the path and query variants needed by the upstream service, but add a database cache in front of it. Each entry has a scope, a URL-derived key and a suitable time-to-live. Fresh cached responses are returned immediately. If an upstream network request fails, the backend can serve the most recent stale response instead. This stale-if-error approach is valuable for information such as prior classifications or rider metadata, where slightly older data is substantially better than no data.
Different information receives different cache lifetimes. A session schedule can be refreshed every few minutes, while event and category metadata can last much longer. The final assembled schedule also has a short memory cache and request coalescing, preventing a burst of clients from rebuilding the same response concurrently.
Rider photographs, circuit assets and derived data
Media needed its own solution. Rider photos and track images come from upstream metadata and image services, but repeatedly loading them from those origins would slow the app and make it dependent on another service for every screen.
MotoTiming resolves an asset once and stores its bytes, content type, source and expiry in a MySQL-backed asset cache. The image endpoints then serve that local copy with normal browser cache headers. When a refresh fails, a stale stored asset can still be returned. A maintenance script can warm or refresh rider photos in advance, moving this work away from race-day page loads.
The backend also turns captured timing into features that do not exist as a single upstream response. During races and sprints, it records snapshots at lap transitions. Once a session finishes, those snapshots are processed into reusable lap-chart data. Race stories, timelines, pace views, position charts and rider comparisons combine classifications and lap analysis through app-specific endpoints. Processing and caching this data once is much more efficient than asking every device to interpret thousands of raw timing updates.
Notifications and device services
MotoTiming supports both web push and native push, so the backend has to handle two delivery systems. Browser subscriptions use VAPID/web push, while the native app requests an Expo push token and registers it with the backend. Registration validates the token and stores notification preferences and favourite riders.
The notification worker uses schedule data to send reminders before a session and analyses live race state for leader changes, fastest laps, favourite-rider movement, suspected crashes, confirmed pit/out states and race or sprint winners. Preferences allow these alerts to be enabled independently, including a favourite-only mode.
Notification delivery must be idempotent. Dedicated notification-history tables use unique keys for the recipient, session and notification type. An INSERT IGNORE claim is made before sending, preventing a polling loop or process restart from sending the same winner or session alert repeatedly.
The app also integrates Expo Calendar so a user can add a session to the device calendar. Optional analytics are handled through PostHog, but remain off until both a project key is configured and the user opts in. Events cover areas such as screen views and preference changes; access codes, push tokens and rider search text are intentionally excluded.
Designing the in-app purchase flow
The native app uses RevenueCat on top of Apple's and Google's billing systems. RevenueCat provides one entitlement model across both platforms while the stores remain responsible for presenting and completing the payment.
I modelled paid access as a single pro entitlement rather than checking for a product identifier throughout the UI. RevenueCat offerings contain the store products, and the app selects the configured package or identifies an annual package by its RevenueCat type, subscription period or product metadata. This makes the integration tolerant of a custom offering or package identifier instead of relying solely on one hard-coded dashboard layout.
At startup, the subscription provider configures the correct platform API key and loads customer information and offerings in parallel. It keeps a listener active for customer-information changes, so the interface responds when an entitlement changes. The paywall takes its price directly from the store product returned by RevenueCat, avoiding a hard-coded price that could be wrong for another country or currency.
When the user chooses Subscribe yearly, the app passes the selected package to RevenueCat. A successful purchase refreshes customer information and unlocks the app only when the pro entitlement is active. User cancellation is treated differently from a genuine error so the app does not show an alarming failure message for an intentional cancellation. Busy states disable duplicate taps while a transaction or restore is running.
The free Event and Settings areas remain accessible, while Live, Schedule, Grid, Results, rider tools and premium alert controls pass through a reusable subscription gate. This centralises the access decision and avoids inconsistent checks across individual screens.
The paywall includes the subscription duration, store-provided price, feature summary, restore action, privacy policy and terms link. If RevenueCat is not configured, if there are no offerings, or if an offering has no attached package, the app exposes a useful state rather than silently displaying a broken purchase button. Customers can also open the management URL returned by RevenueCat to manage an existing subscription.
Protecting existing paid customers
Changing an app from a paid download to a free app with subscriptions creates a product and engineering problem: previous customers should not be asked to pay for access they already bought.
MotoTiming handles this using RevenueCat's original App Store purchase metadata. On iOS, the original purchase date must be earlier than the exact paid-to-free cutoff timestamp. If an original application version is also available, it can be checked against the last paid build as an additional restriction. The build number is never used alone, because after a pricing change it is not sufficient proof of when the customer originally acquired the app.
The normal Restore purchases flow restores subscriptions and legacy purchase information. A separate sync action helps refresh historic store data. Pro access is the union of an active subscription and a verified legacy purchase, which gives established customers a transparent migration path without weakening access for new free downloads.
Getting the native app running reliably
Because MotoTiming includes native libraries—RevenueCat, notifications, PostHog and other Expo modules—it cannot be fully exercised inside Expo Go. I used a custom Expo development client and EAS build profiles, with a consistent Metro port for local development. The same codebase targets iOS, iPadOS and Android, but each still requires platform-specific configuration: bundle/package identifiers, RevenueCat public SDK keys, notification credentials, Android SDK setup and store build numbers.
Environment variables are passed into Expo configuration so builds receive the correct API base URL, EAS project ID, RevenueCat entitlement and offering settings, analytics configuration and legacy-purchase cutoff. Production EAS builds use remote automatic build-number increments, reducing the chance of an upload being rejected because a version code was reused.
Real-device testing was necessary for push permissions and tokens, while simulators were useful for navigation, layouts, API states and purchase iteration. The backend is always addressed through an HTTPS deployment on physical devices; a phone cannot use a development machine's localhost as though it were its own backend.
Testing live sport without waiting for a race
One of the largest testing challenges was time. Many important states—lap changes, crashes, the chequered flag and winner notifications—only happen for a few minutes during a real event. Waiting for the next race would make development extremely slow and make regressions hard to reproduce.
I built raw capture and replay into the ingestion system. Successful live payloads can be archived separately from the latest-state snapshots. A replay tool can list captured sessions, filter by event or category, preserve the original timing or accelerate it, trim quiet periods before and after a race, limit the payload count, and run in a loop. Replayed data travels through the same database and /api/timing path as real data, allowing the app to be tested as if an event were live.
A dry-run mode validates the selected capture without writing. Push delivery is disabled during replay unless explicitly requested, which reduces the risk of sending development alerts to real subscribers. When push testing is deliberately enabled, the same idempotency protections apply.
The replay workflow made it possible to test:
- a session changing from waiting to live and then finished;
- position, lap, gap and pit-status changes;
- suspected crash, out and winner detection;
- lap chart generation at the end of a race;
- stale and malformed timing payloads;
- UI polling and loading states under realistic update frequency;
- behaviour after an ingestor or app restart.
Subscription testing required a separate state matrix. Development-only configuration can force the paywall, free, subscribed or legacy state so every navigation path can be checked without repeatedly purchasing. RevenueCat's Test Store or the platform sandbox is then used to exercise the actual package-loading, purchasing, cancellation, restore and entitlement-refresh flow. A simulator app can be uninstalled to create a fresh anonymous RevenueCat user; if store state remains attached, the test customer or purchase can be reset, with a simulator erase kept as the final clean-state option.
Testing therefore happens at several levels: deterministic replay for live data, direct API and cache-state checks for the backend, simulator checks for layouts and subscription gates, and production-style builds on physical devices for notifications and store integrations. The repository does not currently contain a conventional automated unit or end-to-end test suite; replay tooling and explicit state controls provide strong integration coverage, while automated tests remain a logical future improvement for transformation, entitlement and notification rules.
Difficult problems and the decisions behind them
Handling traffic concentrated around session start
The system had to avoid multiplying identical work by the number of users. A single upstream ingestor, database-backed APIs, short memory caches, in-flight request coalescing and client polling jitter work together to flatten the spike.
Remaining useful during upstream failures
External data is cached by its rate of change. Stale-if-error behaviour, live snapshots and local media storage allow existing information to remain available while an upstream service recovers.
Converting race data into product features
Raw timing is optimised for transmission, not explanation. Normalising it into session and rider state, recording meaningful lap transitions, and processing finished sessions made higher-level race stories and comparisons possible.
Making paid access fair
The app checks an entitlement rather than trusting a completed purchase call, supports restoring and managing purchases, and verifies legacy iOS customers by original purchase date. This covers new subscribers, existing subscribers, cancellations, reinstalls and previous paid-download customers.
Testing rare, fast-moving states
Capturing and replaying real timing payloads turned live races into repeatable test fixtures. Forced development subscription states provided the equivalent control for the paywall, while real Test Store and device builds verified the native boundary.
Outcome
MotoTiming developed from a live timing interface into a multi-service product with a shared data platform. The architecture supports web and native clients without making either one directly dependent on the live upstream feed. It combines rapid live updates with longer-lived schedules, results and media, and it continues to provide useful cached information through short external failures.
For users, that work appears as a simple experience: open the event, follow timing, inspect the grid and results, compare riders, receive the alerts that matter, and restore a purchase after reinstalling. Behind that experience is a deliberately layered system designed for race-day load, imperfect third-party data and the edge cases of mobile commerce.
Technology used
- Expo and React Native for iOS, iPadOS and Android
- React Navigation for native navigation
- Next.js and React for the backend API and web application
- Node.js for ingestion, replay and background processing
- MySQL 8 for live state, caches, assets, captures and notification records
- RevenueCat for App Store and Play Store subscriptions and entitlements
- Expo Notifications and web push/VAPID for native and browser alerts
- Expo Calendar for device calendar integration
- PostHog for optional, consent-based product analytics
- Docker Compose and Dokploy for deployment
- EAS Build for native development and production builds
What I learned
The main lesson was that real-time product development is largely about controlling boundaries. The client should not carry the complexity of every upstream provider. A cache is not only a performance feature; it is also a reliability tool. A purchase is not the same as an entitlement. A successful simulator run is not a replacement for a physical-device test. And live data becomes dramatically easier to develop against when it can be captured and replayed deterministically.
Those principles shaped MotoTiming into an application that is easier to operate, test and extend—and one that can keep presenting a clear race story while the data underneath it changes every second.