The Padel Society · Book a coach

Booking a coach costs 17–23 seconds of spinners. It should take one.

I measured the live flow on prod (app at origin/main 6c31c5ab, iOS Patch 41 / Android Patch 42) for Pad Thai Padel Rama IV (club 673) and Canggu (club 688). The database is not the problem. The edge functions call the same OpenRally endpoints over and over, one coach at a time. Below: where the time goes, a redesigned flow you can click through, the architecture that gets first paint under 300 ms, and the order I'd ship it in.

Measured 14 Oct 2026, 05:40–06:40 UTC · Read-only: function logs, pg_stat_statements, EXPLAIN, and timed calls of the app's own requests with the anon key · Nothing was deployed or written

Top 3 causes

All server-side times come from function_edge_logs.execution_time_ms on prod over 48 h (13–14 Oct). Each card says what it's based on.

6.8 sp50 per slot tap

Each slot tap asks OpenRally the same question 3 times

get-coaching-availability-v2 computes the club's full coach availability three times in a row: isCoachAvailableAtTime, then fetchCoachCourtsForFrame, then getCoachAvailabilityV2 again. Each pass lists the coaches, then fetches each coach's availability one after another. The prod trace shows three rounds of about 2 s each (2.4 + 1.9 + 2.1 s). p95 is 8.7 s and the max is 10.7 s, across 123 taps.

3.6 sp50 to open the screen

Opening the screen builds court availability for every club, then throws it away

get-coach-availability-v2 runs two branches in parallel. Each takes about 3 s. One builds court-slot grids for every nearby club from three Redis blobs, plus a PostHog flag check, and keeps only the club names. The other asks for coach availability across all clubs: 23 legacy coaches whose clubs had 0 coach bookings in 30 days, then each Setpoint coach one at a time × 10 dates. Users without a saved location get a 3.4 MB response (9.5–15.9 s), and some of those calls fail outright.

~67OpenRally calls per user session

The fan-out hits OpenRally's rate limit, and the app hides the failure

All TPS users share each club's API key, and OpenRally allows 50 burst / 300 per minute per key. Opening the screen costs about 31 calls on the Rama IV key, and each tap about 15. When I ran 3 sessions in parallel, OpenRally returned 429s. 10 of 31 real Rama IV slots came back as "no coach available" because 429s are logged and turned into empty results. Run one at a time, all 10 showed coaches.

A fourth problem, about conversion rather than speed: the screen hides slots less than 12 h away, but Setpoint rejects lesson holds less than 19 h away (coaching_advance_hours). In the last 24 h, 6 of 7 create-coaching-booking-v2 calls returned 400, each after 5.5 s. The logs show the hold failing with "Coaching sessions must be booked at least 19 hours in advance." So users wait 17 s or more, and most of them then see an error.

Today's flow, as a waterfall

Entry: Play "+" menu → Book a coach → BookingCoachingScreen → BookingCoachingController.onInit. Server times are prod p50, plus about 0.3 s Bangkok→Singapore network per call, which I measured from here. Thinking time is not included.

0 s 5 10 15 20 25 s 1 · Open screenget-coach-availability-v2 3.6 s p50 (8.6 s p95) · 38 KB · no-location users: 9.5–15.9 s, 3.4 MB 2 · Tap a dateclient-side filter only instant: no network 3 · Tap a time slotget-coaching-availability-v2 pass 1pass 2pass 3 6.9 s · coaches appear only now 3b · Tap another slotavg 2.4 taps per open +6.9 s per extra tap (123 taps ÷ 51 opens) 4 · Bookcreate-coaching-booking-v2 5.8 s · 6/7 failed (19 h rule) 5 · Pay / booking detail court-grid branch (discarded) coach branch (the long pole) OpenRally pass (the same work done again)
≈ 17 sof spinners for a one-tap session (3.9 + 6.9 + 5.8)
≈ 23 sfor a two-tap session (sessions average 2.4 taps)
3×identical OpenRally passes per slot tap
0.2 msthe DB query the GIN index fixed (now irrelevant)

Inside one slot tap (Rama IV, 21 Oct 10:00, traced on prod)

02.5 s5 s6.7 s boot + parse0.2 s (boot 101 ms p50, so cold start isn't the problem) A · court combos ∥ coach pass 12.4 s: 3 facility-availability calls (60/90/120) + list + 3 coaches, one after another B · coach-court pass 21.9 s: same list + same 3 coaches again C · availableCoaches pass 32.1 s

The response is 522 bytes: two coach names and one court. Producing it takes three passes. The whole tap could be one lookup in an object the client already holds.

Measurements

Edge functions on prod, 48 h (13–14 Oct)

FunctionRole in flowCallsp50p95MaxStatus
get-coach-availability-v2Open screen513.4–3.8 s5.4–8.6 s15.7 sall 200
get-coaching-availability-v2Each slot tap1236.8–6.9 s7.7–8.7 s10.7 sall 200
create-coaching-booking-v2Book115.5–6.1 s7.1 s7.2 s6 of 7 = 400 (24 h)
confirm-coaching-booking-v2Server-side, after any booking830.7 s2.4 s2.9 s81 of 83 = 500
sync-coach-slotsCron, legacy coaches only4028–32 s42–52 s55 s200
inspect-coaches-availabilityCalled by sync432.0 s3.5 s5.2 s200
openrally-lesson-webhookSetpoint → TPS lesson events1280.6–1.0 s2.9 s6.0 s2 non-200

Boot time per invocation, from function_logs "booted": 84–183 ms p50, 244 ms max. Almost every call boots fresh (85 boots for 79 calls), but a ~100 ms boot is noise next to 6.8 s.

The app's own requests, timed from Bangkok (14 Oct, 3 runs each)

Request (exact app body)Wall timeServerPayloadResult
Open screen · user has a saved location4.0–4.5 s4.0–4.5 s38 KBRama IV only; Sat 17 + Sun 18 empty
Open screen · no saved location (userLocationId: null)11.0–15.9 s8.7–15.8 s3.4 MB1× "Error on getting clubs list" with HTTP 200, 1× HTTP 546
Open screen · searched "Canggu" (lat/lng)9.5–12.0 s9.4–11.9 s849 KBOne club-day carries 420 KB of user_distance_to_club rows; one run timed out
Slot tap · Rama IV 673 (Setpoint)6.7–7.1 s6.6–6.7 s522 B2 coaches, 1 court combo
Slot tap · Canggu 688 (Setpoint)4.2–4.9 s≈4.2 s466 B1 coach (only 1 coach linked to TPS)
Slot tap · legacy 388 / RSC 527 (not Setpoint)0.79–0.97 s0.78–0.92 s57 BExits early: no coach has slots. ~0.8 s to say "nothing"
OpenRally edge RTT (unauthenticated 401, Fly sin)0.18 s——Network is cheap. The per-coach serial loop is what's slow.

Database (pg_stat_statements + EXPLAIN ANALYZE)

The DB adds well under 100 ms to either call. Nearly all of the time goes to serial HTTP calls to OpenRally and to Redis, plus computation whose result is thrown away.

Where the time goes, by bucket

BucketOpen screen (3.6 s)Slot tap (6.8 s)Evidence
OpenRally/Setpoint upstream (serial per coach)≈ 2.0 s≈ 6.1 sTraces of a Dart call at 05:37Z and of my 06:01Z calls
Wasted fan-out (legacy coaches, court grid, Redis blobs)≈ 1.3 s on the critical path≈ 0.3 sNative branch runs before the OpenRally branch (sequential awaits)
Postgres< 0.1 s< 0.05 spg_stat_statements / EXPLAIN
Cold start0.1 s0.1 s"booted (time: N ms)"
Network BKK↔SIN + JSON0.3 s0.3 swall time minus execution_time_ms

Other defects found along the way

Coverage

Canggu shows 1 coach. Setpoint has more.

Every Canggu pass logs one real coach (padthaipadel.com address) as coach_not_on_tps and one as inactive_tps_profile. The app only knows coaches through e-mail matches to TPS users, so those coaches are invisible. The Bali facility also lists 4 demo coaches (democlub.io), which suggests demo data in prod Setpoint.

Correctness

Under load, the screen shows real coaches as unavailable

Any OpenRally error, 429 included, becomes an empty result. A Dart-client call at 05:37Z (a real user or a teammate's simulator) already hit 429s on a Rama IV coach for 3 dates. Separately, Jose Mouliaa is the busiest Rama IV coach (168 confirmed in 60 d) and had only one open slot all week (Tue 20 Oct 11:00).

Correctness

Two coaches can be shown on one court

The client copies combinations.first to every coach without a combo of its own (baseCombination ??= combinations.first). On Rama IV, 2 coaches came back with 1 combo, so the second coach's Book button carries the first coach's court.

Hygiene

A read path that can write, and a 500 on every booking

resolveMatchedCoaches can upsert coach_profiles from inside a GET-style call (0 times in the last 24 h, so my timed calls wrote nothing). confirm-coaching-booking-v2 runs for consecutive booking IDs and returns 500 "No coach reservations found" for 81 of 83. That's log noise that will hide real failures.

Quick wins I can ship before the revamp

All of these are backend-only, so they need no app release. Estimates assume 3 Rama IV coaches. Each one goes to you as a PR on a branch, and nothing deploys without your go.

#ChangeEffortExpected effect
Q1Make the 3 passes in get-coaching-availability-v2 one: compute getCoachAvailabilityV2(clubId) once and derive "any coach free", the court map and availableCoaches from it0.5 dTap 6.8 s → ~2.3 s
Q2Fetch coaches in parallel inside fetchCoachesForClub / fetchCoachCourtsForFrame (Promise.all instead of for…await), capped at 4 concurrent0.5 dEach pass ~1.9 s → ~0.6 s. With Q1: tap ≈ 0.9 s. Open stays ~3 s until Q5, because the court-grid branch is the other long pole
Q3Upstash cache for OpenRally (facility, coach, date) availability with a 60 s TTL. Bust it from the existing openrally-lesson-webhook1 dRepeat taps and opens ~0.3 s. Calls to OpenRally drop about 10×, so the 429s and the fake empty results go away
Q4Apply the facility's coaching advance rule (19 h at Rama IV) in availability instead of the hard-coded 12 h. Expose it on the club payload0.5 dStops the 6/7 booking failures. This is the largest conversion gain on the list
Q5get-coach-availability-v2: drop the court-grid branch (query courts metadata only for clubs with coaches), run the native and OpenRally branches in parallel, and skip legacy clubs with no coach slots in Upstash1 dOpen 3.6 s → ~1 s (~0.3 s with Q3)
Q6Never embed user_distance_to_club unfiltered (null userLocationId). This also fixes Book a court0.25 d3.4 MB → 38 KB, and 9–16 s → normal for users without a location
Q7Fix the combo-copy bug, and stop calling confirm-coaching-booking-v2 for non-coaching bookings0.5 dCorrect court per coach, and a clean signal in the logs
Q1 + Q2 + Q4 + Q5 take about 2.5 days, need no app release, and take a two-tap session from ~23 s to about 9 s: open ~1 s, each tap ~0.9 s, booking still ~5.8 s until Phase 1. They also remove most failed bookings. I'd start there, with Q4 first.

The new flow: club → date → all coaches → one tap

The prototype below runs on real coach display names and open slots for Rama IV and Canggu. I swept them read-only from prod using the app's own slot call (14 Oct, 06:00–06:40 UTC). It shows initials instead of photos; nothing else personal is included. Prices are each coach's usual rate from TPS reservations over 60 days.

9:41●●● ▮
Book a coach

Private lessons · pick a club, a day, a coach

Pad Thai Padel Rama IVBangkok · 3 coaches
Change
All times Morning Afternoon 60 min 90 min Semi-private
✓
You're booked

1 · Choose the club first, and the app remembers it

The club card sits at the top. The default is the last club you booked. If you have none, it's the club where you've played most, and if you have no history, the nearest one. Tapping "Change" opens a short sheet (Rama IV, Canggu, and later other academies). Your choice persists, so try reloading this page.

2 · The date strip shows open-slot counts

Each day shows how many lessons are open. Empty days are greyed out but still tappable, and they show the next day with openings instead of a dead end (try Sat 17 Oct at Rama IV).

3 · Every coach is visible at once

One card per coach: initials (Setpoint photo later), price per hour, next free slot, and the day's times as chips. This replaces today's two-step "pick a time, wait 7 s, see who's free". Coaches with no slots that day go to the bottom with a "Next: Tue 11:00" link.

4 · One tap holds the slot

Tapping a time places an optimistic hold (Setpoint lesson hold, 5-minute timer) and opens a confirm sheet with price, court and the cancellation rule. "Pay" goes into the existing PMS checkout. If the hold fails, the chip disappears and a toast offers the next slot.

Filters

Morning and afternoon filters, and duration. 90 min and semi-private stay greyed out until Setpoint returns those lesson types. I'm not inventing products the club doesn't sell.

How it relates to Book a court

It uses the same club card, date strip and chip grammar as Book a court, so the two screens feel like one product. Later we can add a "Need a coach?" row on a court slot that deep-links here with the club and date already set.

Empty and error states

  • No coaches that day: show the next day with openings and a jump link.
  • Upstream down: show cached slots marked "updated 2 min ago" and keep Book enabled. The hold is the source of truth.
  • Inside the booking-notice window: slots are hidden server-side, so the user never sees one that would fail.

Architecture: one request per club and date range

The design principle: Setpoint is the source of truth. The edge function aggregates and caches. The client renders and never asks the same question twice.

TPS appSWR cache per(club, week) coach-boardedge fn · GET ?club=673&from=…&to=… (7 d)ETag · stale-while-revalidate Upstashboard:{club}:{date} · TTL 60 s Prefetch cronevery 2 min · 7 days ahead673 + 688 OpenRally → SetpointNEW: GET /facilities/{id}/coach-board?from&toone SQL, all coaches × days 1 requesthit: ~15 msmiss: 1 upstream calllesson webhook busts keywrites

Contract

GET coach-board?club=673&from=2026-10-15&to=2026-10-21
→ { club:{id,name,tz,advance_hours:19,currency:"THB"},
    coaches:[{id,name,photo_url,price_per_hour,durations:[60]}],
    days:[{date:"2026-10-15",open:11,
      slots:[{coach_id,start,end,court_id,court_name}]}],
    generated_at, stale:false }

About 6 KB for a week. It replaces 1 + 2.4 calls with 1, and the board already contains the answer to every slot tap.

Booking without double-booking

  • Tapping a slot calls create-coaching-booking-v2, which calls the OpenRally lesson hold. That's the authoritative lock in Setpoint, which already enforces conflicts, the advance rule and the 19 h window.
  • On success, write a coach_pending_slots row (TTL = hold expiry) and remove the slot from the cached board right away, so a second user doesn't see it.
  • On 409, mark the slot unavailable in cache, return the next 3 alternatives, and the client swaps in place.
  • Payment goes through the existing PMS checkout. The hold is released on expiry, and the existing openrally-lesson-webhook confirms.

Latency budget

Client SWR (re-open, swipe dates)0 ms paint
Cache hit: boot 0.1 + Upstash 0.015 + BKK↔SIN 0.08≈ 0.2 s
Miss: one coach-board SQL in Setpoint + Fly hop≈ 0.5–0.8 s
Hold + booking row≈ 1.0–1.5 s

Targets: first paint under 300 ms from cache, fresh under 1 s. With the cron warming 7 days ahead, nearly every open is a cache hit.

Fallback for non-Setpoint clubs

Same contract, built from the existing Upstash coach calendar (CoachAvailabilityCalendarService) plus coach_reservations, in one batched read per club (HGETALL per coach in parallel, not 10 serial HGETs). Legacy clubs with zero coach slots don't appear in the picker at all.

If you'd rather not change Setpoint, there's an interim: coach-board aggregates the existing per-coach endpoint in parallel. That's 3 coaches × 7 days = 21 calls per cache miss, which only works behind the cron and the cache.

Plan

Phase 0~1.5–3 days · backend only
Quick wins Q1–Q7. One PR per fix in padel-backend, each tested locally against prod-shaped fixtures. They deploy as Edge Functions only after your go. Success: tap p50 under 1.5 s, open p50 under 1.5 s, create-booking 400s near zero, zero 429s in logs.
Phase 1~4–5 days
Board API. OpenRally GET /facilities/{id}/coach-board (Rust, one SQL, applies the advance rule), coach-board edge function + Upstash + prefetch cron (pg_cron → dispatch, the same pattern as the Playtomic sync), and cache busting from openrally-lesson-webhook. Contract tests on both sides.
Phase 2~5–7 days
New screen in Flutter behind PostHog flag tps-coach-board: club card, date strip, coach cards, confirm sheet with hold timer, SWR via GetX + the existing Dio layer. Sim-verified for 673 + 688. Ships as a Shorebird OTA if no native change is needed. The old screen stays behind the flag until we're confident.
Phase 3~3 days
Coverage + polish. Coach photos and focus from Setpoint, an onboarding path for coaches not linked to TPS (or matching by Setpoint ID instead of e-mail), semi-private and 90-min once Setpoint offers them, and a "Need a coach?" entry from Book a court.

Risks

Your decisions

  1. Go on Phase 0 (Q1–Q7) as backend PRs now?It removes most of the pain in about 2 days and needs no app release. I'd ship Q4 (the 19 h rule) first, because users are hitting that error today.
  2. Add a coach-board endpoint to OpenRally (Setpoint repo), or aggregate on the TPS side only?I recommend the endpoint. It's one SQL instead of 21 calls, and every OpenRally client benefits (PTP app, Activa).
  3. Default academy rule: last booked, then most-played, then nearest. Or should PTP members always land on their home club?
  4. Hold length and copy: 5 minutes to pay, with a visible timer? It must match the PMS checkout timeout.
  5. Coach identity: should Setpoint coaches without a TPS account appear (matched by Setpoint ID), or do coaches have to onboard to TPS first? Today, a Canggu coach is invisible because of this.
  6. Clean up the demo coaches on the Bali Setpoint facility. Setpoint prod is a write, so it needs your go.