A few months ago I rebuilt a booking flow for a Calgary gym, and the fix that actually moved the numbers wasn't a redesign — it was deleting a network boundary. This is a writeup of what was actually broken, the integration decision that fixed it, and why I think this pattern shows up more often than most developers notice.
The setup
The gym's site had a clean, well-built class schedule page. Times, instructors, difficulty tags, all rendered server-side, all fast. The problem was one click away: "Book Now" opened a completely separate third-party reservation portal — its own auth state, its own styling, its own DOM, loaded via a full page navigation.
Functionally, this was two single-purpose apps stitched together with an <a href>. A visitor who'd just found "Tuesday 7am HIIT" had to leave that context entirely, land on an unfamiliar interface, and re-search for the same class from scratch.
I mapped the actual step count between "visitor decided" and "visitor is booked":
1. See class on schedule page
2. Click "Book Now" → full page navigation
3. Land on third-party portal (different styling, cold cache)
4. Prompted to log in or create an account
5. Re-search for the same class in the new interface
6. Select the class again
7. Fill in booking details
8. Receive confirmation, disconnected from the original site
Eight steps, one full context switch, before a first-time visitor — someone with zero account, zero saved payment info, zero reason to push through friction — actually holds a spot.
What we changed
Instead of a redesign, this was an integration problem. The booking portal exposed a JS embed API (most reservation platforms — Mindbody, Glofox, etc. — do), so the fix was pulling the booking widget directly into the schedule page's DOM rather than linking out to it.
// Simplified version of the embed initialization
document.querySelectorAll('.class-slot').forEach(slot => {
const classId = slot.dataset.classId;
slot.querySelector('.book-btn').addEventListener('click', (e) => {
e.preventDefault();
BookingWidget.mount({
target: slot.querySelector('.booking-inline-target'),
classId,
mode: 'inline', // renders in-place instead of a full-page redirect
onComplete: (confirmation) => {
renderConfirmationState(slot, confirmation);
}
});
});
});
The class the visitor already selected gets passed directly as a parameter — no re-search required, because the "search" already happened when they found the class on the schedule in the first place. The booking widget mounts inline, inheriting the page's existing styles rather than swapping to its own theme.
The first-timer problem this exposed
Once the schedule and booking lived in one place, a second issue became obvious: the booking form asked for the same fields regardless of whether the visitor was an existing member or booking their first-ever class. A first-timer hitting a "log in" prompt before they'd committed to anything was a clear drop-off point.
We split this into two explicit paths at the component level rather than trying to make one form handle both cases:
function getBookingMode(visitor) {
return visitor.hasAccount ? 'member-quick-book' : 'first-timer-trial';
}
first-timer-trial skips authentication entirely and asks only for name, email, and phone — enough to hold a spot and send a confirmation, nothing else. Waiver and intake fields moved to a post-booking step, since asking for them before a spot was held was pure friction with no corresponding benefit at that point in the flow.
Why this is also a page-experience problem, not just a UX one
The original flow's full-page navigation into a separate app shell meant every booking attempt paid a full cold-start cost — separate CSS bundle, separate JS runtime, no shared cache with the parent page. That's directly measurable: it's exactly the kind of extra network round-trip that drags down Largest Contentful Paint and Total Blocking Time on whatever page triggers it, and Core Web Vitals are a confirmed factor in how Google evaluates page experience for ranking.
Embedding the widget in-place collapsed that into a single render pass sharing the existing page's cache and style context. The speed improvement wasn't something we optimized directly — it fell out of removing an architectural boundary that didn't need to exist.
I wrote a bit more about this same pattern — the cost of unnecessary context switches and client-side-only rendering hiding content from crawlers — over on my post about what a 40% conversion lift taught me about speed and SEO, if you want the fuller breakdown of the Core Web Vitals side of this.
Result
Class bookings increased 40% after launch. The full case study, with more on the business side of this project, is up on DevGurux if you want the non-code version.
The generalizable part
If you're building or reviewing any "browse then commit" flow — reservations, appointment booking, event registration, checkout — the diagnostic that actually matters isn't "does this look good," it's counting the literal number of page loads, re-authentications, and context switches between "user decided" and "user committed." Every one of those is a place a motivated user becomes an unmotivated one, and in my experience it's almost always higher than people assume until they actually count it.
Curious if others have run into the same pattern outside of booking flows specifically — checkout flows seem like the obvious parallel, but I'd guess this shows up in onboarding flows too. Let me know if you've dealt with something similar.
Top comments (0)