Day 8: Matchmaking queue and the lobby
Today covers the part of the app between “pick a distance” and “the countdown starts”. It is the most stateful code outside the race itself. There are HTTP calls, a channel subscription, a polling fallback, three timers, app lifecycle events, and a user who can hit back at any moment. QueueNotifier handles all of that in about 270 lines, and the way it does so is the pattern the race screen repeats on Day 11.
Before you start
Section titled “Before you start”Day 7 gave you WaitingChannel: join waiting:<slug>, read the join reply, listen to matches. Day 6 lined up POST /api/matchmaking/join with MatchmakingController. You also need the routes from Day 4: /queue/:raceType and /race/:id (app/lib/app/router.dart:99-114). The end-to-end version of today is the matchmaking flow.
The Race tab: an AsyncNotifier in a widget
Section titled “The Race tab: an AsyncNotifier in a widget”RaceTypesNotifier
Section titled “RaceTypesNotifier”Open app/lib/features/race_tab/race_types_notifier.dart. build() (lines 8-10) just returns dashboard() from the repository. The interesting part is refresh() (lines 14-23):
Future<void> refresh() async { final previous = state.value; state = const AsyncValue<List<RaceType>>.loading().copyWithPrevious(state); state = await AsyncValue.guard( () => ref.read(raceTypeRepositoryProvider).dashboard(), ); if (state.hasError && previous != null) { state = AsyncValue.data(previous); }}copyWithPrevious makes a loading state that still carries the old list, so the screen does not flash a skeleton on pull-to-refresh. AsyncValue.guard runs a future and wraps the outcome as AsyncData or AsyncError, so you do not write the try/catch yourself. Kotlin’s runCatching is the nearest match. The last three lines mean a failed refresh keeps the old list.
RaceTab.build
Section titled “RaceTab.build”Open app/lib/features/race_tab/race_tab.dart:24-127. Three reads at the top show the three Riverpod verbs together:
final raceTypes = ref.watch(raceTypesProvider);final auth = ref.watch(authProvider).value;// ...ref.listen<AsyncValue<String?>>(pendingRaceProvider, (_, next) { final id = next.value; if (id != null) context.go(AppRoutes.race(id));});ref.listen inside a widget’s build is allowed. Riverpod registers it once and removes it when the widget goes away. It is the right tool for navigation, which is a side effect, not something to render. pendingRaceProvider (app/lib/features/race/race_notifier.dart:403-421) checks the stored active race id against the server. So an app killed mid-race reopens on the Race tab and is sent straight back to /race/<id>.
raceTypes.when(loading:, error:, data:) at lines 61-123 is the standard way to render an AsyncValue. It is exhaustive, much like when over a Kotlin sealed class. A retry calls ref.invalidate(raceTypesProvider) (line 67). That throws away the provider’s state and runs build() again, the Riverpod version of restarting a process.
Tapping a card goes to _confirm (lines 129-147). It shows a bottom sheet and awaits its result. showModalBottomSheet<bool> returns a Future<bool?> that completes when the sheet calls Navigator.pop(context, true) (line 185). Then it asks for location permission before queueing (“A race is worthless without tracking”, line 141). Only then does it context.push(AppRoutes.queue(type.slug)). push, not go, so the queue screen sits on top of the tab and back returns there.
!context.mounted at line 140 is the same guard as mounted from Day 5, for code that has a BuildContext but no State.
The queue: a sealed state machine
Section titled “The queue: a sealed state machine”The states
Section titled “The states”Open app/lib/features/queue/queue_notifier.dart:15-62:
sealed class QueueState { const QueueState(); }
class QueueIdle extends QueueState { /* */ }class QueueJoining extends QueueState { /* */ }class QueueWaiting extends QueueState { // competitorId, since, polling}class QueueMatched extends QueueState { // raceId, startsAt, opponentName, opponentIsBot}class QueueCancelled extends QueueState { /* */ }class QueueError extends QueueState { // message, canRetry}stateDiagram-v2 [*] --> QueueIdle QueueIdle --> QueueJoining: start() QueueJoining --> QueueMatched: join reply has race QueueJoining --> QueueWaiting: join reply queued QueueJoining --> QueueError: ApiException QueueWaiting --> QueueMatched: match push or poll hit QueueWaiting --> QueueError: poll 404 QueueJoining --> QueueCancelled: cancel() QueueWaiting --> QueueCancelled: cancel() QueueError --> QueueJoining: start() again
If you have written a gen_statem, this will feel familiar, though nothing enforces the transitions. Each method checks state is X by hand before acting. What sealed does enforce is the other side: every switch over a QueueState must handle all six cases, or the build fails.
The provider: autoDispose and family
Section titled “The provider: autoDispose and family”Lines 71 and 266-267:
class QueueNotifier extends AutoDisposeFamilyNotifier<QueueState, String> {
final queueProvider = NotifierProvider.autoDispose .family<QueueNotifier, QueueState, String>(QueueNotifier.new);These are two Riverpod modifiers. family makes the provider a function of an argument. Here that is the race-type slug, so queueProvider('five_km') and queueProvider('marathon') are separate instances. Inside the notifier, the argument is arg (line 91). Think of a Registry keyed by slug, or a {:via, Registry, {Queues, slug}} name. autoDispose destroys the instance when nobody watches it any more. When the queue screen pops, the notifier’s ref.onDispose (lines 84-87) stops polling and leaves the channel. That is your terminate/2. Without autoDispose, a cancelled queue would keep its timer running for the life of the app.
build(String arg) returns QueueIdle synchronously. This is a plain Notifier, not an AsyncNotifier. The async work happens in start(), and the loading state is modelled by hand as QueueJoining.
start(): queue over HTTP, listen on the socket
Section titled “start(): queue over HTTP, listen on the socket”The class comment (lines 64-70) states the design:
The server keeps one queue shared by HTTP and the
waiting:<slug>channel, so we queue over HTTP and listen on the socket for thematchpush. PollingGET /api/competitors/:id/pollevery [pollInterval] is only a fallback for when the socket will not connect.
That is backend/lib/racing/matchmaking.ex:1-14 seen from the client. The code does not say why it queues over HTTP rather than with WaitingChannel.joinQueue(). My reading is that HTTP works whether or not the socket is up, and that the channel join gets its answer from the server either way. start() (lines 94-118):
Future<void> start({bool bot = false}) async { if (state is QueueJoining || state is QueueWaiting) return; state = const QueueJoining(); try { final result = await _repo.join(arg, bot: bot); if (state is QueueCancelled) { await _repo.leave(); return; } final race = result.race; if (race != null) { _matched(race, myCompetitorId: result.competitorId); } else { state = QueueWaiting( competitorId: result.competitorId, since: ref.read(clockProvider)(), ); await _listenForMatch(); } } on ApiException catch (e) { state = QueueError(e.message, canRetry: e.statusCode != 400); }}Read the check right after the await. Every await is a point where other code can run. Here that other code is cancel(), from the user tapping Cancel or back. So after each await the notifier asks “am I still in the state I expected?” A GenServer would never need this, because its mailbox serialises calls. A Dart notifier has no mailbox. Lines 99-104 handle the case where the HTTP join lands after a cancel. The server now has a fresh competitor that the earlier leave missed, so it calls leave again.
since: ref.read(clockProvider)() reads the injectable clock (app/lib/core/clock.dart:7), a Provider whose value is a function. Tests override it to control time.
_listenForMatch (lines 122-141) joins waiting:<slug>, checks state is! QueueWaiting after the await (the same race again), stores the StreamSubscription from channel.matches.listen(_onMatchPush), and stops polling. Lines 135-136 handle a match that happened before the socket connected. The join reply already says matched (backend/lib/footrace_web/channels/waiting_channel.ex:97-99). If the join throws ChannelException, _fallBackToPolling (lines 143-152) sets polling: true and starts the timer.
Timers and the poll
Section titled “Timers and the poll”void _startPolling() { _timer?.cancel(); _timer = Timer.periodic(pollInterval, (_) => _pollOnce());}Timer.periodic (line 221) is :timer.send_interval/2, or a Handler.postDelayed loop on Android. _pollOnce (lines 229-251) uses _pollInFlight so that a slow poll never overlaps the next tick. The lobby uses the same guard. A 404 from the poll means the request is gone. The backend prunes requests older than 10 minutes (backend/docs/channels.md:149-150), so that becomes “Your place in the queue expired.” Any other error is logged and the timer keeps going.
cancel() (lines 179-190) sets QueueCancelled before doing anything slow, “so the screen never shows a stale waiting”. Then it leaves the channel and calls DELETE /api/matchmaking/leave. It only calls the server if it was queued or joining.
Two small things that do not match what you might expect:
_onMatchPush(lines 154-164) setsopponentNamebut notopponentIsBot.MatchCompetitordoes not decodebot(app/lib/data/socket/waiting_channel.dart:21-34). A bot race always matches on the HTTP reply, though, and_matched(lines 253-263) does set it, so the push path only sees humans in practice.pollingstaystrueafter a later socket join succeeds._listenForMatchstops the timer but does not copy the state withpolling: false. The test atapp/test/features/queue/queue_socket_test.dart:149-165asserts exactly that. Nothing inQueueScreenreadspolling, so for now it is only a flag for tests.
The queue screen: lifecycle, timers, hand-off
Section titled “The queue screen: lifecycle, timers, hand-off”Open app/lib/features/queue/queue_screen.dart. _QueueScreenState mixes in WidgetsBindingObserver (line 36). A Dart mixin adds members to a class without inheritance, a bit like a Kotlin interface with default methods plus state. This one gives the screen didChangeAppLifecycleState, which is the onPause/onResume of a Flutter app.
initState (lines 41-50):
_tick = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {});});Future.microtask( () => ref.read(queueProvider(widget.raceTypeSlug).notifier).start());The one-second timer has no logic. An empty setState forces a rebuild so the “Waiting 0:07” text and the countdown move. start() is deferred with Future.microtask because start() assigns state at once, and Riverpod forbids changing a provider while the widget tree is building. The microtask runs right after the current frame’s build. dispose (lines 53-58) cancels both timers and removes the observer.
The lifecycle handler (lines 61-71) arms a one-minute backgroundGrace timer on paused. If the app stays in the background that long, polling pauses. On resumed it cancels the timer and calls resumePolling(). The notifier then polls once, because a dropped socket may have missed the match, and re-joins the channel if it has none (app/lib/features/queue/queue_notifier.dart:202-217).
build (lines 93-149) switches over the sealed state with patterns:
child: switch (state) { QueueIdle() || QueueJoining() => const _Centered(/* spinner */), QueueWaiting(:final since) => _Waiting(since: since, /* ... */), QueueMatched() => _Matched(state: state, now: now), QueueCancelled() => MessageState(/* ... */), QueueError(:final message, :final canRetry) => MessageState(/* ... */),},QueueWaiting(:final since) is an object pattern. It matches the type and binds the since field to a local, much like %QueueWaiting{since: since} in an Elixir function head. || joins two patterns. Since QueueState is sealed, deleting the QueueCancelled() line is a compile error. In dev builds the waiting view also shows “Race a bot (dev)”, which cancels and calls start(bot: true) (lines 122-127).
PopScope (lines 101-105) blocks the system back gesture while waiting or joining. It calls _cancel() instead, which tells the server before leaving.
The hand-off to the race
Section titled “The hand-off to the race”_maybeHandOff (lines 73-85) runs on every build while matched:
final remaining = startsAt == null ? Duration.zero : startsAt.difference(ref.read(clockProvider)());if (remaining <= QueueScreen.handoffLead) { _navigated = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) context.go(AppRoutes.race(matched.raceId)); });}The backend sets starts_at 30 seconds after the match (race_starts_after_ms: :timer.seconds(30) at backend/config/config.exs:13). The queue screen shows the opponent and counts down, and the one-second tick drives the rebuilds. At five seconds out (handoffLead, line 26) it navigates, “so the race screen can warm up the GPS” (lines 16-19). Navigation is a side effect, so it goes in addPostFrameCallback, which runs after the current frame. _navigated makes sure it happens once. context.go, not push, replaces the stack, so back from the race does not land on a finished queue.
The lobby: polling with stale data
Section titled “The lobby: polling with stale data”Open app/lib/features/lobby/lobby_notifier.dart. LobbyNotifier is an AutoDisposeAsyncNotifier<LobbyState>. build() (lines 43-48) loads GET /api/lobby once, starts a two-second Timer.periodic, and registers stopPolling in onDispose. No socket. The backend has no lobby channel, and LobbyController.index lists pending match requests (backend/lib/footrace_web/controllers/api/lobby_controller.ex:12-20).
poll() (lines 60-82) keeps the last good list on failure and sets stale after two misses in a row. That is a deliberate UX choice from the lobby PRD (the comment at lines 28-32). The screen shows a “Reconnecting…” banner instead of an empty list (app/lib/features/lobby/lobby_screen.dart:125, 258-288).
Joining someone (app/lib/features/lobby/lobby_screen.dart:48-82) confirms with a sheet, checks permissions, stops polling, and calls POST /api/lobby/:id/join. On success it goes straight to context.go(AppRoutes.race(race.id)). There is no queue screen, because the race already exists. _errorCopy (lines 84-88) turns the controller’s 404 (“Match request not found or already matched”) and 400 (“Cannot join your own match request”) into friendlier copy.
How does the runner you picked find out? They are on their own queue screen, subscribed to waiting:<slug>. LobbyController.join calls MatchRequests.join_match_request/2, which ends in handle_match/2. That calls Racing.Matchmaking.broadcast_matched(race) (backend/lib/racing/match_requests.ex:141), and their WaitingChannel process pushes match. That arrives in their _onMatchPush. It is the same path as an ordinary queue match. The lobby is just a different way to call handle_match.
Try it
Section titled “Try it”1. Run the queue tests under fake time.
flutter test test/features/queue/Open app/test/features/queue/queue_notifier_test.dart:18-26. fakeAsync from the fake_async package replaces timers and microtasks with a virtual clock. async.elapse(const Duration(seconds: 4)) (line 41) fires two poll ticks instantly. c.listen(queueProvider('five_km'), ...) on line 21 matters too. Without a listener the autoDispose provider would be destroyed between reads. These tests use the default FakeSocket, which has no reply scripted for waiting:five_km, so the channel join throws and the notifier falls back to polling. That is why they can count polls.
2. Change the poll interval and watch the right test fail. Set pollInterval at app/lib/features/queue/queue_notifier.dart:72 to Duration(seconds: 3) and rerun flutter test test/features/queue/queue_notifier_test.dart. “queued, then matched on the third poll” fails at line 42, which expects exactly two polls after four seconds. Revert it.
Optional, needs an emulator and a local backend: run the app with DEV_TOOLS=true, pick 100 m, wait on the queue screen, and tap “Race a bot (dev)”. Watch the countdown on _Matched and the jump to the race at five seconds.
Check yourself
Section titled “Check yourself”- Why does
start()checkstate is QueueCancelledafter the HTTP join returns, and what does it do about it? - What does
autoDisposebuyqueueProvider, concretely? - Why is
start()called fromFuture.microtaskininitStaterather than directly? - When does the queue screen navigate to the race, and why then rather than at the match?
- Runner A waits in the queue. Runner B picks A from the lobby. Which code path tells A?
Answers
- The user may have cancelled while the request was in flight.
cancel()already calledleave, but before the new competitor existed, sostart()calls_repo.leave()again (app/lib/features/queue/queue_notifier.dart:99-104). - When the queue screen goes away and nothing watches the provider, Riverpod disposes it.
onDispose(lines 84-87) then stops the poll timer and leaveswaiting:<slug>. start()setsstateat once, and Riverpod does not allow changing a provider during a widget build. The microtask runs just after.- When
startsAtis five seconds or less away (QueueScreen.handoffLead), with the 30-second server countdown. That gives the race screen time to start GPS before the gun (app/lib/features/queue/queue_screen.dart:73-85). LobbyController.joinleads toMatchRequests.handle_match/2, which callsbroadcast_matched(backend/lib/racing/match_requests.ex:141). A’sWaitingChannelprocess pushesmatch, and A’sQueueNotifier._onMatchPushsetsQueueMatched.
Day 9: GPS, permissions, the foreground-service isolate covers what happens after the hand-off: the permission sheet you passed through today and the service that tracks the run.