It's my money and I need it now! - JG Wentworth Commercial
The actors in this nostalgic commercial (90's baby) are demanding their money and want it instantly, as a kid the only solution I could think of was to have a JG Wentworth representative in every large city with boat load of cash - getting anything out of thin air instantly seemed unfathomable.
The commercial was ahead of its time, today we expect everything instantly - Netflix Shows, Amazon Products, Social Media Posts, and in the case of GOLFTEC users want to improve their golf game and want to get started now.
We had a problem though, our marketing website and booking platform were built on a home-brewed PHP CMS which was starting to show its age. The booking platform took 15 seconds to load appointments, forced users to re-enter information on every visit due to no session memory, fetched our APIs on every reload or page visit, and provided no visibility into abandoned carts.
I had six months to rebuild it, to make it work and to make it fast.
The Challenge
Huge annual sale is coming, we are down to the wire
The Legacy System
- Performance baseline: 20+ seconds of waiting on API responses per booking flow
- API overhead: 15+ calls per booking flow
- Concurrent users: Hundreds during peak hours (golf tournaments and sale periods)
-
User experience issues:
- Slow Loading
- Confusing Flow
- Nearby locations unclear
- No data on booking funnel
- Dated UX
Business Constraints
- Timeline: 6 months from kickoff to launch
- Availability requirement: 99.9% uptime (revenue-critical application)
- Feature parity: Maintain 100% of existing functionality
- Stakeholders: Marketing needed new features and metrics
Technical Constraints
-
Backend:
- Limited bandwidth for backend services
- Not much flexibility with core service
- Team size: 2 engineers for backend support, me, myself, and I to build frontend
Solution Architecture
Support several apps - prioritize simplicity, less dependencies is more
Core Architectural Decisions
The platform was rebuilt with three key architectural principles:
- Minimize network requests: Intelligent caching and request batching
- Optimize perceived performance: UI Skeleton Feedback, Pre-fetching, and split modules where possible
- Maintain simplicity: Avoid over-engineering; choose boring tech where possible
Tech Stack Selection
Frontend:
- React + TypeScript: I wanted tried, true, and well documented. The idea was that any engineer with even some JS/TS could hop in and make changes. I had also previously worked in a large project previously that was vanilla JS, opting for TS and having access to interfaces and type safety was a no-brainer.
- Context API for shared state: There are more than one ways to skin a cat and the same is true for sharing state. I would have only chosen Redux for resume-building, not because the project needed it - Context API provided all of the functionality I needed and did not bloat my dependency tree.
- Material UI: The Figma designs for the booking platform were not anything too specialized and naturally closely followed Material UI's structure. I also wanted to leave margin for custom logic and functionality, CSS overrides were also a small price to pay for a beautiful UI.
- Local Storage: The booking platform had previously fetched APIs every reload for data that was mostly static (GT Center data, user data, etc). To make the platform faster I knew I wanted to persist this data between reloads.
- Webpack: The application(s) needed to be bundled and I wanted to use something that easily introduced code splitting and chunk caching.
Backend:
- PHP Middleware to Java Backend: This was mostly already set in stone, but served useful since we had a couple of dedicated PHP engineers to create these new APIs bespoke to our use case. It also helped that we could reuse infrastructure from old PHP CMS, load balancers and all.
- REST APIs: Because we were able to tailor our APIs, GraphQL was not needed. Anything that was not useful was cut from requests and responses.
Infrastructure:
- AWS S3: Once compiled, that application files were static and needed to be easily fetched from the client, with our organization already utilizing AWS, S3 was an easy choice for hosting the application file.
- CDN Strategy: The booking platform needed to support users across the globe (Dubai, South Africa, Japan, China, Canada, etc). The only difference between these applications were formatting items like currency, API endpoints, and language which the client could figure out at runtime. However, we needed to deliver the application quickly to all users so I setup a CloudFront instance, by default caching was enabled for 24 hours.
Deep Dive: Performance Optimization
1. Caching Strategy
The Issue
The legacy system made redundant API calls:
- Locate user on page load
- Relocate user on every additional page load
- Fetch center data every time user is located
- Require user to re-enter information on every reload
- Fetch pricing data on every load
- Require user to re-enter information when changing location
The Solution
Client-side caching with smart validations
class Storage {
static EXPIRATION = 14 * 24 * 60 * 60 * 1000; // 14 days
static getData(key: string){
const item = localStorage.getItem(key);
if (!item) return null;
const { data, timestamp } = JSON.parse(item);
if (Date.now() - timestamp > this.EXPIRATION) {
this.deleteData(key);
return null;
}
return data;
}
static setData(key: string, data: any){
localStorage.setItem(key, JSON.stringify({
data,
timestamp: Date.now()
}));
}
}
// example usage
const centerData = Storage.getData('center_details');
if (!centerData) {
const data = await fetchCenterDetails(centerId);
Storage.setData('center_details', data);
}
Key Decisions
Data availability: We noticed that users would typically not book on their first visit (page or session) and also saw that users would typically come to the website from our Google maps listings (they have already vetted their most nearby location). All of this data Center Details, User Details, UTM parameters, etc would mostly be static so we decided on 14 days of localStorage or until a user booked.
Pre Fetching: In the previous tool, we would wait for interaction before starting off the process for fetching center data. In the new environment I looked for specialized tags within the DOM that would set the appropriate center and fetch data via API, the default though was using device GPS which would be requested on every page load, and the fallback was automatic GeoIP where we would not need user opt-in.
Results: Cutting out a lot of these redundant calls we saw that on average we went from 15 to 5 (66% reduction) and any number over the average was out of necessity - to change selected location or user details.
2. State Management Architecture
Why not Redux?
Redux is great and can be very useful, but just as ore is not useful on an airplane, Redux would not have been useful in the build of this application.
State Requirements
- Booking flow state (4 steps, 6 user fields)
- User session data (userID, handedness, etc)
- UI state
Why Context API Won:
- Simplicity: Redux required ramp up time for myself as well as adding to our dependency list, something I was very intentional about keeping short.
- Performance: Component tree depth was 5 levels, re-render optimizations with
useMemo(() => {}, []);
was more that sufficient. - Bundle size: Redux + middleware would have added additional bloat and modules to our bundle, again something I was very intentional about keeping small.
// CONTEXT EXAMPLES
/*
- I had 4 main contexts Appointments, Booking, Center, and Service
- These would keep track of things like activeStep, selectedService, responseStatusCode, etc.
- I could be very intentional about the structure of my app and components and where I wanted this data shared
*/
interface BookingContextProps {
...
}
const BookingContext = createContext<BookingContextProps | undefined>(undefined);
interface ServiceContextProps {
...
}
const ServiceContext = createContext<ServiceContextProps | undefined>(undefined);
...
3. Concurrent Bookings
Race Condition Problem
I think we lucked out a bit here, we had very few instances of truly concurrent bookings especially since we were booking for 250+ locations, but did add some safeguards to ensure there were no false-positives.
We freshly fetched appointments every time the appointment page was reloaded, having stale data here was not an option. To ensure there was no room for the edge case of double bookings, we added a queue that would be invoked by location ID, not allowing other bookings at that center until the previous job completed. In the front end we made allowances with Alerts and created statusCodes specifically in the event that a user tried to book a slot that was just taken.
4. Progressive Loading & Perceived Performance
Code-Splitting: In the main App.tsx
file I added a lazy import for all modules that were not required for first paint, this did have some blow back for increased API latency but was solved with more intentional deeply nested lazy(() => import('<module>'));
statements.
// App.tsx
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoadingSkeleton from './components/LoadingSkeleton';
// Eagerly loaded (needed for first paint)
import Header from './components/Header';
import LocationSelector from './components/LocationSelector';
// Lazy loaded (defer until needed)
const BookingFlow = lazy(() => import('./modules/BookingFlow'));
const AppointmentScheduler = lazy(() => import('./modules/AppointmentScheduler'));
const CheckoutPage = lazy(() => import('./modules/CheckoutPage'));
function App() {
return (
<BrowserRouter>
<Header />
<LocationSelector />
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/book" element={<BookingFlow />} />
<Route path="/schedule" element={<AppointmentScheduler />} />
<Route path="/checkout" element={<CheckoutPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Bundle Optimization: In the webpack config file I segregated three different types of files - bookingCore; contained utils, types, apis, etc.. vendors; contained node modules, commons; was inclusive of all components. Initial first paint bundle size went from 570kb to 100kb dramatically improving load times.
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
// Third-party vendor code (React, MUI, etc.)
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
chunks: 'all',
},
// Shared application code
commons: {
name: 'commons',
minChunks: 2,
priority: 5,
chunks: 'all',
reuseExistingChunk: true,
},
// Booking-specific utilities
bookingCore: {
test: /[\\/]src[\\/](utils|types|apis)[\\/]/,
name: 'bookingCore',
priority: 8,
chunks: 'all',
},
},
},
},
};
UI Updates: Thankfully, MUI made this extremely simple with some out of the box and easily configurable skeletons. Any component that contained data from our APIs was shown immediately with a 1:1 Skeleton component, we only had one spinner in the entire application - this made everything feel lightning fast.
Backend Enhancements: We were able to also find some issues with how we were fetching and booking appointments with redundant and overly verbose DB calls, fixing these helped dramatically.
5. Monitoring & Observability
Metrics Tracked:
- Page load time
- Time to interactive
- API error rates
- Booking completion rate
- Booking Funnel
Tools used:
- Datadog (Real User Monitoring RUM, Application Performance Monitoring APM, Session Replay)
Session Replay was particularly valuable—we could watch actual user sessions where bookings failed and identify UX issues that metrics alone didn't reveal.
Results & Impact
The numbers below alone made this project worth the time, however there were some items that were harder to measure but echoed loud still. This new tool was much easier to iterate on with new features, bugs decreased and new bugs were easier to debug, lastly this changed the way we thought about tooling and future projects.
Metric | Before | After | Improvement |
---|---|---|---|
Page load time | 6s | 3s | 50% reduction |
API calls per booking | 15 | 5 | 66% reduction |
Booking completion rate* | 8% | 10% | 25% increase |
*Completion rate = % of users who start booking flow and complete checkout
What I'd Do Differently
If I Had More Time
- Create a true hold function that locked an appointment as soon as a user selected it and hold it for 5 minutes, further reducing the possibility of a double-booking edge case.
- I would more aggressively prefetch data, on hover of buttons and for nearby locations, making the navigation to the schedule book seem instantaneous.
Lessons Learned
- Try Early, Try Often: Early in the project, I was too scared to try different approaches and break stuff (I really just wanted to make sure I was able to ship on time). Ultimately this cost me some time in the long run, I had to backpedal anyway and well after I should have.
- Think About Scale: I don't mean this in user count but in regards to additional applications, this project began as a single booking platform but quickly grew to needing other tooling for the website. At first I stood up repos for each of these projects which quickly became a mistake. I ended up creating a mono-repo shortly after the initial launch.
- Flesh Out The Details: At times it is easy to stop asking questions to stakeholders in an effort to appear as if we have everything under wraps and understand it all. This is and was a mistake, there were several back-and-forths that I could have avoided if I asked more questions earlier on.
- All That Glitters Ain't Gold: In development shiny and cool tooling seem necessary at times, and a lot of the time, for most use cases they are not. They can add unnecessary bloat and complexity to our projects. Like Dave Ramsey famously asks "Do I really need this library (car, jet, meal)".
- Monitor Everything: What cannot be measured cannot be improved.
Connect With Me
Building solutions that drive revenue and solve real business problems make me excited, if you're working on similar problems or have questions about this project, feel free to reach out:
Top comments (0)