Go back to articles

Building SpinningFlow: From Late-Night Idea to Real-Time Workout Engine

How I built a bike spinning workout app with Elixir, Phoenix LiveView, and a healthy disregard for doing things the easy way.

AI disclosure: This article was edited and formatted with the help of AI (Claude). All facts, opinions, technical claims, names, and links are my own responsibility.


There’s a moment in every indoor cycling class where the instructor yells “add resistance!” and you pretend to turn that knob while secretly wondering if anyone would notice if you just… didn’t. SpinningFlow was born from the opposite impulse: what if you could design your own workout, synced to your own music, with nobody watching except a screen that knows exactly where you should be?

The idea is simple. Connect your music service, pick songs or playlists, set your fitness level and goal, and get a timed workout plan with cadence, resistance, and power cues synced to the music. The execution? Less simple. This is the story of building it.

Get a preview here and hit me up for an invite code: https://spinningflow.net/

The Stack: Why Elixir and Phoenix for a Fitness App?

Let me get this out of the way: yes, I could have built this with Next.js and a REST API. Yes, it might have been faster to prototype. No, I don’t regret what I chose instead, since I am working with this stack for years now and feel much more comfortable with it.

Backend: Elixir / Phoenix 1.8 / LiveView

The core of SpinningFlow is a Phoenix JSON API with LiveView for the web interface. Here’s why that matters: when you’re playing a 45-minute workout with real-time segment transitions, countdowns, and live cadence cues, you need a system that handles concurrent state without breaking a sweat. Elixir’s BEAM VM was built for exactly this. Each active workout session runs as its own GenServer process — a lightweight, isolated state machine ticking every second, broadcasting updates via PubSub. If the user navigates away from the player page, the workout keeps running. Come back, and it picks up right where it was.

Contrast this with a typical JS/WebSocket approach: you’d need explicit reconnection handling, state reconciliation on the client, and careful lifecycle management. With Phoenix, I get all of that from the framework’s plumbing. LiveView reconnects automatically. PubSub broadcasts are trivially cheap. The GenServer is the session state.

The full stack:

Layer Choice Why
Language Elixir 1.18 / OTP 26 Concurrency model, fault tolerance, pattern matching
Framework Phoenix 1.8 + LiveView 1.1 Real-time UI without the SPA tax
Database PostgreSQL 16 + Ecto Robust, battle-tested, great migration system
Auth Guardian (JWT) + Ueberauth API tokens for the Flutter client, OAuth for music service
Background jobs Oban For future music service sync and audio feature fetching
HTTP client Req Clean API, composable, the modern Elixir standard
Frontend Tailwind CSS v4 + esbuild Utility-first, no build complexity
Client (planned) Flutter iOS, Android, Web, Desktop from a single codebase
Deployment Docker multi-stage + docker-compose Minimal runtime image, reproducible builds

Architecture: Contexts, Boundaries, and a GenServer That Won’t Quit

Phoenix encourages organizing business logic into contexts — bounded modules with clean public APIs. SpinningFlow has two core ones:

  • Accounts — Users, authentication, invite codes, profile management
  • Training — Plans, segments, presets, and the player session lifecycle

The rule is strict: modules in one context never reach into another’s schemas. If Training needs to know about a user, it goes through Accounts.get_user!/1. This sounds academic until you try to refactor something six months later and realize past-you was actually being kind to future-you.

The Player Session: Where It Gets Interesting

The heart of SpinningFlow is the PlayerSession GenServer. When you hit play on a workout, the system spawns a dedicated process under a DynamicSupervisor, registered with an Elixir Registry keyed to your user ID. It manages a state machine with four phases:

:ready → :countdown (5s) → :active → :transition (5s prelap) → :active → ... → :done

Every second, the GenServer ticks:

  • Decrements the remaining time on the current segment
  • When 5 seconds remain and a next segment exists, enters :transition — a preview overlay where you see what’s coming while finishing the current block
  • When time hits zero, advances to the next segment or finishes the workout
  • Broadcasts a state snapshot via PubSub after every tick

The LiveView subscribes to these broadcasts and renders the UI. But the GenServer doesn’t need the LiveView. If every browser tab closes, the session quietly keeps ticking for up to 30 minutes before timing out. Open a new tab, navigate to the player — your workout is still going.

This is the kind of architecture that’s trivially hard to get right in most web frameworks and almost trivially easy in Elixir. The BEAM was built for this.

The Preset Engine: Making Interval Training Not Terrible

Designing a workout plan shouldn’t require a sports science degree. The Presets module contains a reference power table mapping RPM ranges (40–130) across 16 intensity levels, distilled into a 1–10 user-facing scale. Each segment type has a default cadence:

Type Default RPM Feel
Warmup 70 Easy spin, build heat
Steady 80 Cruise pace
Interval 100 High cadence, short bursts
Climb 50 Low cadence, high resistance
Recovery 70 Active recovery
Cooldown 60 Wind down

Pick a segment type and intensity, and the system auto-fills RPM, watts, and resistance. Override the RPM? Watts recalculate. It’s a small UX detail that makes the plan builder feel smart rather than like a spreadsheet with a nicer font.

Design Principles: What Guided the Decisions

1. Server-Authoritative State

The server is the single source of truth. Always. The player GenServer doesn’t trust the client to tell it what segment it should be on. The preset engine doesn’t let the client compute watts. Forms validate server-side with Ecto changesets. This isn’t paranoia — it’s the foundation for eventually supporting a mobile client (Flutter) that connects via the JSON API and expects consistent behavior.

2. Invite-Only by Default

SpinningFlow launches with an invite code system. The first user gets unlimited invite generation. Everyone else can create 2 codes per week. This isn’t growth hacking — it’s scope management. A closed system lets me iterate without the pressure of “but what about the 10,000 users who…” scenarios. The landing page says it plainly: “SpinningFlow is a private community.”

3. Progressive Disclosure Over Feature Dumps

The dashboard shows quick stats and your most recent plans. The plan editor reveals segment controls one at a time via a bottom drawer. The training chart visualizes your workout profile as colored bars — width proportional to duration, height to power output. You see the shape of your ride before you start it.

4. LiveView-First, API-Ready

The web interface is 100% LiveView — no SPA, no client-side routing, no bundle splitting anxiety. But the JSON API exists in parallel, with JWT auth via Guardian. When the Flutter client arrives, it authenticates against the same endpoints and gets the same data. Two front-ends, one brain.

The Good

LiveView is a genuine superpower for prototyping. I went from “I want a plan editor with drag-to-reorder segments” to a working implementation in a single session. No API endpoints to wire up. No state management library to configure. No optimistic updates to debug. Change something on the server, and the DOM updates. It’s the web as it should have been.

The GenServer model for real-time sessions is elegant. A workout session is naturally a stateful, time-driven process. Modeling it as a GenServer with PubSub feels like the right abstraction, not a workaround. Each session is isolated, supervised, and recoverable. If the process crashes, the DynamicSupervisor can restart it (though currently I let it die — sessions are ephemeral).

Ecto changesets make form validation delightful. The plan editor has fields for duration (30 min – 2 hours), difficulty (easy through intense), and goal (endurance, intervals, climb, recovery). All validated with changesets, with errors surfacing automatically through the <.input> component. Adding a new validation is one line. Testing it is three.

The training chart hook is surprisingly satisfying. Pure JavaScript, rendered in a phx-hook, receiving segment data via push_event. Each bar is colored by type (amber for warmup, red for intervals, purple for climbs), width scales with duration, height with wattage. It updates in real-time as you add segments. Building this as a lightweight JS hook rather than pulling in a charting library was the right call — it’s maybe 200 lines and does exactly what I need.

The Bad

music service integration is still mostly plumbing. The OAuth setup is there. The user schema has music service_id and music service_refresh_token fields. Ueberauth music service is configured. But the actual flow — fetching playlists, analyzing audio features, matching BPM to cadence — doesn’t exist yet. This is the marquee feature, and it’s the one I’ve been circling around. The music service Web API is well-documented but deep. Audio features alone (tempo, energy, danceability) require a dedicated Oban worker pipeline to fetch and cache. I’ve declared the Oban dependency. The workers remain aspirational.

The UUID migration is a half-step. I started with auto-incrementing integer IDs (Phoenix default) and then realized I’d need UUIDs for the mobile client and any future multi-device sync. The migration script exists to convert all tables from serial to binary_id, but it’s complex — add UUID columns, populate them, swap foreign key references, drop old constraints. It works locally, but it’s the kind of migration that makes you check your backup strategy twice before running in production.

Testing coverage is honest but incomplete. There are solid tests for presets (78+ cases covering power lookup tables), invite codes, API auth, and the main LiveView flows. But the PlayerSession GenServer — arguably the most critical piece — is tested through the LiveView integration tests rather than unit-tested in isolation. Testing concurrent stateful processes in Elixir is straightforward with tools like :sys.get_state/1 and Process.monitor/1, but I haven’t done the disciplined work of covering all the edge cases: what happens when you pause during a transition? What if two browser tabs both try to stop the session?

The Ugly

I built the whole plan builder before building the music service integration. In hindsight, the music service connection should have been the first spike — a proof-of-concept that answers “can I actually sync a workout to music in a way that feels good?” Instead, I built a robust plan editor, segment system, preset engine, real-time player, invite system, and deployment pipeline. The core value proposition — music-driven workouts — is the part that doesn’t exist yet. This is classic builder’s bias: I built what was fun to build, not what was risky to validate.

Dark mode was an early priority. I regret nothing, but I acknowledge the absurdity of spending time on accent color variables (accent-500 for light, accent-600/accent-700 for dark) before I could actually play a music service track. In my defense, I was spending a lot of time looking at the app.

The Flutter client is still theoretical. The JSON API endpoints exist. Guardian issues JWT tokens. The auth flow is tested. But the Flutter project lives in a separate repo that, as of writing, mostly contains good intentions. The API-first design will pay off — if I build the client.

From Prototype to Product: The Paths Forward

SpinningFlow is currently a working prototype. You can register (with an invite code), build a workout plan with typed segments, visualize it, and play through it in real-time with countdown transitions. That’s a complete loop, minus the music.

Here’s what the road to product looks like:

Path 1: music service-First MVP

Complete the music service integration. OAuth flow, playlist fetching, audio feature analysis (BPM, energy), and an auto-builder that constructs segment plans from playlist tracks. This transforms SpinningFlow from “manual workout builder” to “music-driven workout generator” — the actual pitch.

Required work:

  • music service OAuth callback flow (Ueberauth wiring)
  • music serviceClient module (playlist/track/audio-features endpoints via Req)
  • Oban workers for background audio feature fetching
  • PlanBuilder that maps track BPM/energy to segment types and intensity
  • Music context with Song and Playlist schemas

Path 2: Social + Community

The invite code system is already a social mechanic. Extend it: let users share plans, follow each other, see ride stats. Phoenix PubSub can power a live leaderboard during simultaneous rides. This turns SpinningFlow from a solo tool into a community.

Path 3: Flutter Client + Offline

Build the Flutter client with API sync. Support offline playback — download plan data, cache music service tracks (within API terms), and run workouts without connectivity. This is where UUIDs, the JWT API, and the plan/segment data model pay their rent.

Path 4: Hardware Integration

Bluetooth connection to smart trainers (Wahoo KICKR, Tacx) and heart rate monitors. Control resistance programmatically based on segment targets. This is the “it’s a real product” moment, and it’s a substantial engineering lift — but the segment model already stores target watts and resistance values, so the data layer is ready.

Tech Reflections

Building SpinningFlow has reinforced a few things I already believed and taught me a few things I didn’t expect:

Elixir/Phoenix is underrated for rapid prototyping. The common perception is that Elixir is for “scalable backend systems” — which it is. But LiveView makes it equally good for moving fast on front-end-heavy features. The feedback loop of saving a .ex file and seeing the browser update instantly (no webpack, no HMR delays, no hydration bugs) is addictive.

GenServer is one of the best abstractions in all of programming. That’s not hyperbole. A supervised process with a clean state machine interface, message-passing concurrency, and built-in timeout handling — this is the erlang/Elixir ecosystem’s gift to application developers. My workout player is maybe 200 lines of GenServer code. An equivalent in most languages would require a timer service, a state store, a pub/sub adapter, and careful thread-safety considerations.

The hard part is never the tech. Building the plan editor was straightforward. Designing the preset power tables took research. Making the training chart feel good took iteration. But the hardest part? Deciding whether this should be a product or a project. I’m still deciding.

What’s Next

The immediate roadmap:

  1. music service OAuth flow — the missing keystone
  2. Audio feature pipeline — Oban workers to fetch/cache BPM and energy data
  3. Auto-builder — generate plan segments from playlist track analysis
  4. Flutter client — MVP with plan playback and music service control
  5. Unit tests for PlayerSession — the GenServer deserves its own test suite

SpinningFlow is a prototype that works. It’s also an exercise in building something where the most interesting engineering challenge (music-synced real-time workouts over GenServer processes) met the most interesting product challenge (does anyone besides me actually want this?).

I suspect the answer is yes. But I won’t know until I finish the music service integration and hand someone an invite code.


SpinningFlow is built with Elixir 1.18, Phoenix 1.8, LiveView 1.1, PostgreSQL 16, and arguably too much enthusiasm. The source is private, the invite codes are weekly, and the dark mode is flawless.