Case Study:

Page Speed Optimization

Improved performance by restructuring rendering, reducing client-side work, and leveraging caching—driving consistent gains across key page types.

Context

I was looking to improve the performance of a direct booking SaaS platform - specifically the listing and details pages, which were critical for user acquisition and conversion. These pages had gradually become slower over time, and I wanted to understand why and how to fix it.

The platform runs on a multi-tenant Next.js setup where each host gets a customized landing page. Over time, as more features were added to support flexible layouts, integrations, and richer content, the pages started getting heavier—especially on mobile.

There wasn’t a single breaking change that caused this. It was more of a slow build-up: a few extra client-side fetches here, slightly larger images there, a couple of third-party scripts added over time. Individually, none of these felt like a big deal. Together, they started to add up.

Eventually, it reached a point where performance issues became visible outside engineering. Pages felt slow to load, interactions lagged, and Core Web Vitals dropped. This started affecting bounce rates on paid traffic, SEO rankings, and overall conversion on listing pages.

I picked this up as a focused effort to understand where the load time was actually going and how much of that work was avoidable.

Key Finding:

The pages worked, but they were doing too much work on the client, too early in the lifecycle.

  • Large un-optimized images (especially hero + galleries) were dominating load time
  • Too many scripts were loading upfront instead of progressively
  • A lot of data fetching was happening on the client via useEffect
  • Third-party scripts were competing for main thread time
  • There was no clear performance budget or guardrails

Approach

A big early realization was around caching and rendering. A lot of the pages were effectively being rebuilt for every user, even though most of the underlying data didn’t change that often. At the same time, we were relying heavily on client-side fetches, which meant the browser was doing a lot of work after the page had already started loading.

The direction from there was fairly straightforward: move predictable work earlier (server-side or cached), and reduce how much the browser has to figure out at runtime.

Planned Optimization Strategy:

Refactored loading strategy

Steps Taken

[1] Shift data fetching server-side

Replaced repeated client-side fetches with SSR where possible, and cleaned up duplicate calls (like shared host/settings data being fetched multiple times). This reduced main-thread work, but also meant being careful about not slowing down the initial render too much.


[2] Get images under control

A lot of LCP issues were just large assets. Resizing, switching to WebP, and making sure only the important images load first had a noticeable impact pretty quickly.

  • Converted assets to WebP
  • Proper sizing (no more 3000px images in 400px containers)
  • Introduced responsive image loading
  • Lazy-loaded below-the-fold content
  • Optimized hero image loading priority

[3] Trim down JavaScript

Looked at what was actually needed on the client vs what was just there by default. Removed unused imports, split bundles, and deferred anything that didn’t need to run immediately.

  • Deferred non-critical JS
  • Removed unused libraries
  • Split bundles (code splitting)
  • Optimized font loading (no layout jumps)

[4] Clean up network noise

Removed redundant API calls and delayed third-party scripts so they weren’t competing with core page rendering.

  • Delayed analytics + trackers
  • Loaded scripts after interaction where possible
  • Removed redundant integrations

[5] Layout stability (CLS fixes)

  • Reserved space for images/components
  • Avoided dynamic content shifts
  • Fixed font loading jumps

Summary:

There wasn’t a single “fix”—it was more about gradually reducing unnecessary work and rebalancing where things happen. Along the way, there were some tradeoffs (especially between faster paint vs less JS execution), so it ended up being a bit iterative rather than one clean pass.

Results

Results summary showing improvements across key metrics and page types

Key Takeaways

Most of the gains didn’t come from anything exotic — they came from stepping back and questioning what actually needed to happen on the client.

  • Images were doing more damage than expected. Large, unoptimized images were quietly dominating load times. Fixing sizing and delivery gave immediate, noticeable improvements without touching core logic.
  • Client-side work had crept in over time. A lot of data fetching and logic had slowly moved into the browser. Pulling that back to the server reduced blocking time and made the app feel faster, even when raw load times didn’t change dramatically.
  • Caching wasn’t being fully leveraged. The system was recomputing things more often than necessary. Introducing better caching (especially for HTML) reduced repeat work and stabilized performance across users.
  • Simplifying the system had compounding effects. Removing redundant calls and unnecessary code didn’t just improve performance — it made the system easier to reason about, which made further improvements faster and safer.

Results Deep-Dive and Technical Walkthrough

[P1] — Landing Page

Improved mobile performance from 45 → 57 by removing redundant calls, optimizing images, shifting work server-side, and reducing JS bundle cost.


Baseline

Score: 45
Issues: heavy client-side work, unoptimized images, redundant calls.

PageSpeed baseline showing high JS execution, image weight, and blocking resources driving poor LCP and TBT
Initial state — high JS execution, image weight, and blocking resources driving poor LCP/TBT.

Step 1:

Remove Redundant Calls + Image Optimization

  • Eliminated duplicate API calls
  • Introduced optimized image handling
  • Immediate improvement in load behavior

Score: 50

PageSpeed after removing redundant calls and optimizing images
Early gains — reduced network noise + lighter images improved load consistency.

Step 2: Reduce Client-Side Work

  • Moved more logic server-side
  • Identified remaining client calls: location (IP-based) and search (featured listings—constrained by legacy flags)
Client-side execution breakdown identifying unnecessary browser work
Client-side execution breakdown — identifying unnecessary browser work.
Residual API calls after optimization showing only location and listings fetch remain
Residual calls isolated — only location + listings fetch remain.

Step 3: Post-Optimization State

  • Majority of unnecessary client-side execution removed
  • Only essential runtime calls remain
Lean runtime showing minimal client work after optimization
Lean runtime — minimal client work, improved responsiveness.

Step 4: Framework Upgrade (Next.js 15)

  • Improved FCP and LCP
  • Introduced regression in TBT (Total Blocking Time) due to JS execution cost
PageSpeed after Next.js 15 upgrade showing TBT regression
Tradeoff — faster paint metrics but heavier JS caused blocking time regression.

Final State

Score: 57

Final PageSpeed score of 57 after all optimizations
Final state — improved performance after reducing client-side work and optimizing rendering.

[P2] Property List Page

Reduced main-thread blocking from ~1050ms → ~130–320ms and improved LCP from 19.1s → ~13.7–14.5s by moving data fetching server-side and balancing SSR with client execution.

Critical issue — extremely high LCP driven by heavy assets + blocking scripts.

Baseline (Production)

FCP: 5.1s | LCP: 19.1s | TBT: 1050ms | CLS: 0.038 | SI: 15.0s

Property list baseline in production showing extremely high LCP driven by heavy assets and blocking scripts

Baseline (Dev — Cached via CDN)

FCP: 4.4s | LCP: 13.6s | TBT: 740ms | CLS: 0 | SI: 7.4s

CDN impact visible — caching improves load but core inefficiencies remain.

Property list baseline with CDN caching enabled showing improvement but core inefficiencies remain

Move Fetch Calls Server-Side

FCP: 6.3s | LCP: 14.5s | TBT: 130ms | CLS: 0.054 | SI: 8.8s

Major TBT drop — shifting work server-side reduces main-thread blocking, but delays first paint.

Property list after moving fetches server-side showing major TBT drop but delayed first paint

Pre-populate Data + Hybrid Rendering

FCP: 5.3s | LCP: 13.7s | TBT: 320ms | CLS: 0 | SI: 6.8s

Balanced state — better paint timings with acceptable JS cost; highlights tradeoff between FCP and TBT.

Property list after hybrid rendering showing balanced paint timings with acceptable JS cost

[P3] Property Details Page Optimization

Incremental improvements compounded from 31 → 72 through caching, LCP fixes, and system-level optimizations.

Baseline

Performance: 31 | Accessibility: 86 | FCP: 6.9s | LCP: 16.3s | TBT: 1480ms | CLS: 0.002 | SI: 9.3s

High TBT dominates — main-thread blocking is the primary bottleneck despite decent CLS.

Property details page baseline with score of 31 showing high TBT as primary bottleneck

With Cache Enabled (31 → 41)

Caching alone gives a modest bump (31 → 41) by reducing repeat computation, but core bottlenecks remain.

Property details with cache enabled showing score improvement from 31 to 41

LCP Optimization + Cache (41 → 66)

Focused on image optimization, render path improvements, and deferring non-critical JS. This drove a significant boost to 66 by addressing the most egregious LCP blockers and reducing main-thread work.

LCP-focused fixes push performance to ~45 — initial gains from prioritizing above-the-fold content.

Continued iteration (~51) — incremental improvements by refining render path and asset loading.

Compounding gains (~66) — combined effect of caching + rendering + reduced JS execution.

Property details compounding gains showing score around 66

Fall-on Improvements from P1 Optimizations (66 → 72)

Final uplift (~72) driven by upstream improvements — shows how system-level changes cascade across pages.

Property details final score of 72 driven by upstream improvements cascading across pages