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.
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.
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.
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.
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.
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.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.
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.
| Function | Role in flow | Calls | p50 | p95 | Max | Status |
|---|---|---|---|---|---|---|
get-coach-availability-v2 | Open screen | 51 | 3.4–3.8 s | 5.4–8.6 s | 15.7 s | all 200 |
get-coaching-availability-v2 | Each slot tap | 123 | 6.8–6.9 s | 7.7–8.7 s | 10.7 s | all 200 |
create-coaching-booking-v2 | Book | 11 | 5.5–6.1 s | 7.1 s | 7.2 s | 6 of 7 = 400 (24 h) |
confirm-coaching-booking-v2 | Server-side, after any booking | 83 | 0.7 s | 2.4 s | 2.9 s | 81 of 83 = 500 |
sync-coach-slots | Cron, legacy coaches only | 40 | 28–32 s | 42–52 s | 55 s | 200 |
inspect-coaches-availability | Called by sync | 43 | 2.0 s | 3.5 s | 5.2 s | 200 |
openrally-lesson-webhook | Setpoint → TPS lesson events | 128 | 0.6–1.0 s | 2.9 s | 6.0 s | 2 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.
| Request (exact app body) | Wall time | Server | Payload | Result |
|---|---|---|---|---|
| Open screen · user has a saved location | 4.0–4.5 s | 4.0–4.5 s | 38 KB | Rama IV only; Sat 17 + Sun 18 empty |
Open screen · no saved location (userLocationId: null) | 11.0–15.9 s | 8.7–15.8 s | 3.4 MB | 1× "Error on getting clubs list" with HTTP 200, 1× HTTP 546 |
| Open screen · searched "Canggu" (lat/lng) | 9.5–12.0 s | 9.4–11.9 s | 849 KB | One 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 s | 6.6–6.7 s | 522 B | 2 coaches, 1 court combo |
| Slot tap · Canggu 688 (Setpoint) | 4.2–4.9 s | ≈4.2 s | 466 B | 1 coach (only 1 coach linked to TPS) |
| Slot tap · legacy 388 / RSC 527 (not Setpoint) | 0.79–0.97 s | 0.78–0.92 s | 57 B | Exits 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. |
booked_slots WHERE metadata @> '{"reserved_for":"coach"}' AND start/end window now does a Bitmap Index Scan on idx_booked_slots_metadata in 0.17 ms (4 buffers). The cron's version of the same query averages 17 ms over 20.8k calls. Setpoint clubs never take this path anyway, because their holds live in Setpoint.coach_reservations has only a primary key. The per-coach window query scans all 6.4k rows sequentially, but that still takes 1.0 ms. It's worth an index later. It isn't a cause.coach_reservations (33k calls, 14.9 ms mean, 494 s total), from the profile screens.user_distance_to_club holds 26 M rows (3.2 GB). The club query embeds it without a filter when userLocationId is null: 7.1 s mean, 13.1 s max. That explains the 3.4 MB payloads and the "Error on getting clubs list" returned with HTTP 200.| Bucket | Open screen (3.6 s) | Slot tap (6.8 s) | Evidence |
|---|---|---|---|
| OpenRally/Setpoint upstream (serial per coach) | ≈ 2.0 s | ≈ 6.1 s | Traces 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 s | Native branch runs before the OpenRally branch (sequential awaits) |
| Postgres | < 0.1 s | < 0.05 s | pg_stat_statements / EXPLAIN |
| Cold start | 0.1 s | 0.1 s | "booted (time: N ms)" |
| Network BKK↔SIN + JSON | 0.3 s | 0.3 s | wall time minus execution_time_ms |
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.
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).
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.
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.
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.
| # | Change | Effort | Expected effect |
|---|---|---|---|
| Q1 | Make the 3 passes in get-coaching-availability-v2 one: compute getCoachAvailabilityV2(clubId) once and derive "any coach free", the court map and availableCoaches from it | 0.5 d | Tap 6.8 s → ~2.3 s |
| Q2 | Fetch coaches in parallel inside fetchCoachesForClub / fetchCoachCourtsForFrame (Promise.all instead of for…await), capped at 4 concurrent | 0.5 d | Each 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 |
| Q3 | Upstash cache for OpenRally (facility, coach, date) availability with a 60 s TTL. Bust it from the existing openrally-lesson-webhook | 1 d | Repeat taps and opens ~0.3 s. Calls to OpenRally drop about 10×, so the 429s and the fake empty results go away |
| Q4 | Apply 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 payload | 0.5 d | Stops the 6/7 booking failures. This is the largest conversion gain on the list |
| Q5 | get-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 Upstash | 1 d | Open 3.6 s → ~1 s (~0.3 s with Q3) |
| Q6 | Never embed user_distance_to_club unfiltered (null userLocationId). This also fixes Book a court | 0.25 d | 3.4 MB → 38 KB, and 9–16 s → normal for users without a location |
| Q7 | Fix the combo-copy bug, and stop calling confirm-coaching-booking-v2 for non-coaching bookings | 0.5 d | Correct court per coach, and a clean signal in the logs |
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.
Private lessons · pick a club, a day, a coach
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.
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).
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.
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.
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.
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.
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.
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.
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.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.openrally-lesson-webhook confirms.| 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.
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.
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.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.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.coach_profiles, so it needs a careful migration.