It loaded.
The hero appeared.
The buttons worked.
Nothing obvious screamed “performance problem.”
But under mobile conditions, Largest Contentful Paint was taking roughly:
6.0 seconds
That was not a small regression.
It was more than twice the 2.5-second threshold generally considered good for LCP.
So instead of throwing every common performance trick at the page, we treated the problem like a debugging exercise.
One change.
One measurement.
Then the next change.
The final result:
Before: 6.0s LCP
After: 1.2s LCP
Change: -80%
The interesting part was not the final number.
It was discovering that the largest image was not initially the biggest problem.
Here is how the LCP optimization unfolded.
Note: This is a representative performance case study. The measurements are designed to illustrate the debugging and optimization process rather than represent a specific customer project.
First, We Made the Test Repeatable
Performance work becomes unreliable when every change is measured under different conditions.
Before touching the application, we created a repeatable baseline.
The same principle applies to performance and load testing: if the environment and conditions keep changing, the results are difficult to compare or trust.
For each version, we:
- Tested the same URL
- Used the same mobile profile
- Started with a cold cache
- Used the same network conditions
- Ran multiple tests
- Ignored obvious outliers
- Compared the median result
- Recorded the LCP element and timing breakdown
Lab tools helped us iterate quickly.
Real-user monitoring was still important for validating the final result because actual users have different devices, networks, locations, and cache states.
The lab was our debugging environment.
Field data was our reality check.
The Baseline: Where Were the Six Seconds Going?
Looking at a single LCP number was not enough.
We broke it into four parts:
| LCP Component | Baseline |
|---|---|
| Time to First Byte | 1.5s |
| Resource load delay | 2.1s |
| Resource load duration | 1.0s |
| Element render delay | 1.4s |
| Total LCP | 6.0s |
That table immediately changed our approach.
The hero image took about one second to download.
But the browser spent 2.1 seconds waiting before it even started downloading it.
Compressing the image first would have attacked the wrong bottleneck.
Change #1: Fix the 1.5s TTFB
LCP: 6.0s → 5.0s
The document itself was arriving too slowly.
Our initial request involved application work that did not need to happen for every page view.
Conceptually, the request looked like:
Browser
↓
Application
↓
Database/API work
↓
Template rendering
↓
HTML response
Until that HTML starts arriving, the browser cannot discover most of the resources needed to render the page.
We reduced that delay by:
- Caching cacheable HTML responses
- Removing unnecessary server-side work
- Avoiding repeated data fetches
- Moving non-critical work out of the request path
- Serving cached responses closer to users where appropriate
The TTFB moved from:
1.5s → 0.5s
Our new breakdown looked approximately like this:
| Component | After Change #1 |
|---|---|
| TTFB | 0.5s |
| Resource load delay | 2.1s |
| Resource load duration | 1.0s |
| Render delay | 1.4s |
| LCP | 5.0s |
A one-second improvement was useful.
But five seconds was still terrible.
More importantly, the trace now made the next problem impossible to ignore.
Change #2: Let the Browser Discover the Hero Immediately
LCP: 5.0s → 3.1s
This was the biggest single improvement.
Our LCP element was the hero image.
But the browser could not discover it early.
The implementation effectively depended on other resources being processed before the hero request became visible.
Patterns like this commonly cause trouble:
.hero {
background-image: url("/images/hero.webp");
}
If that CSS is in an external stylesheet, the browser needs to:
Receive HTML
↓
Discover CSS
↓
Download CSS
↓
Parse CSS
↓
Discover hero image
↓
Request hero image
The image may be highly compressed.
That does not matter if its request starts two seconds late.
We moved the important visual into the document as an actual image element.
Conceptually:
<picture>
<source
srcset="/images/hero-1280.avif"
type="image/avif"
>
<img
src="/images/hero-1280.webp"
width="1280"
height="720"
fetchpriority="high"
alt="Application dashboard"
>
</picture>
We also verified something important:
loading="lazy"
was not applied to the LCP image.
Lazy loading below-the-fold images is useful.
Lazy loading the element that is supposed to become your largest above-the-fold paint is usually counterproductive.
After the change:
Resource load delay:
2.1s → 0.2s
And overall LCP dropped:
5.0s → 3.1s
That single change saved almost two seconds.
This was the most important lesson from the entire investigation:
Do not only ask how fast the LCP resource downloads. Ask how quickly the browser discovers it.
Change #3: Now We Optimized the Image
LCP: 3.1s → 2.4s
Only now did image optimization become the largest obvious opportunity.
The original hero asset was much larger than necessary.
We addressed three things.
Format
We generated modern image variants instead of sending the same large source everywhere.
Dimensions
A mobile device did not need an image sized for a large desktop display.
Responsive image candidates allowed the browser to choose an appropriate resource.
For example:
<img
src="/images/hero-1280.webp"
srcset="
/images/hero-640.webp 640w,
/images/hero-960.webp 960w,
/images/hero-1280.webp 1280w
"
sizes="100vw"
width="1280"
height="720"
fetchpriority="high"
alt="Application dashboard"
>
Compression
We reduced bytes without making the hero visibly degraded.
The result was a substantial reduction in resource transfer time:
Resource load duration:
1.0s → 0.3s
Overall:
3.1s → 2.4s
We had finally crossed the 2.5-second line in our controlled test.
But stopping here would have meant leaving almost another second on the table.
Change #4: Remove Work Blocking the Paint
LCP: 2.4s → 1.5s
At this point the hero was:
- Discovered early
- Prioritized
- Relatively small
- Downloaded quickly
Yet there was still a noticeable gap between:
Image downloaded
and:
Image painted
That is element render delay.
The culprit was not the image anymore.
The browser's critical rendering path contained resources that delayed the initial render.
We found:
- CSS that was not required above the fold
- Scripts executing before they were needed
- Third-party code competing for the main thread
- Application code doing work before initial content could settle
We started removing things from the critical path.
Non-critical JavaScript was deferred where possible:
<script src="/analytics.js" defer></script>
Non-essential functionality stopped competing with the first render.
We also reviewed the CSS loaded before the hero appeared.
This kind of web performance optimization is easier when frontend architecture, asset loading, and rendering behavior are treated as part of the same performance problem.
The rule was simple:
If the resource is not necessary to display the first screen, why is the first screen waiting for it?
After that cleanup:
Element render delay:
1.4s → 0.5s
LCP:
2.4s → 1.5s
The page now felt dramatically different even though visually almost nothing had changed.
Change #5: Reduce Main-Thread Work Before LCP
LCP: 1.5s → 1.2s
The final improvement was smaller, but it exposed another useful performance pattern.
The main thread was still doing too much work early.
Large JavaScript bundles were being:
downloaded
↓
parsed
↓
compiled
↓
executed
before some of that functionality was actually needed.
We inspected long tasks occurring before LCP and asked:
- Does this code need to execute immediately?
- Can this component initialize after the initial render?
- Can this bundle be split?
- Is a third-party script blocking useful work?
- Are we hydrating components that are below the fold?
- Are we shipping JavaScript for features that are not used on this route?
We delayed non-critical initialization and reduced early JavaScript execution.
The remaining render delay dropped:
0.5s → 0.2s
That brought the final measurement to:
1.2s LCP
Every Change, Side by Side
Here is the complete progression:
| Step | LCP | Improvement |
|---|---|---|
| Baseline | 6.0s | — |
| Faster TTFB | 5.0s | -1.0s |
| Earlier LCP discovery | 3.1s | -1.9s |
| Responsive optimized image | 2.4s | -0.7s |
| Remove render-blocking work | 1.5s | -0.9s |
| Reduce early main-thread work | 1.2s | -0.3s |
Overall:
6.0 seconds
↓
1.2 seconds
An 80% reduction.
But the table also shows why random optimization is inefficient.
Our largest improvement did not come from compressing the image.
It came from starting its request earlier.
The Final LCP Breakdown
The baseline:
TTFB 1.5s
Resource load delay 2.1s
Resource load time 1.0s
Render delay 1.4s
-----
LCP 6.0s
After optimization:
TTFB 0.5s
Resource load delay 0.2s
Resource load time 0.3s
Render delay 0.2s
-----
LCP 1.2s
That decomposition became much more useful than the Lighthouse score itself.
A score tells you something is wrong.
A timing breakdown tells you where to look.
What We Did Not Do
Performance work also requires knowing which optimizations not to make.
We Did Not Lazy-Load the Hero
Lazy loading is valuable for resources outside the viewport.
The LCP image was immediately visible.
Delaying it made no sense.
We Did Not Preload Everything
Preload is not a general-purpose “make the site faster” switch.
Making every resource high priority means nothing is actually high priority.
We reserved resource priority for things that genuinely affected the initial experience.
We Did Not Optimize Only for Lighthouse
Lab measurements helped us compare changes quickly.
But real users do not all have the same CPU, connection, viewport, cache state, or geographic location.
Field performance still mattered.
We Did Not Start by Removing Random JavaScript
“JavaScript is slow” was not a useful diagnosis.
We identified which main-thread work overlapped the LCP window and targeted that work specifically.
Our LCP Optimization Checklist Changed After This
Before this investigation, it was tempting to start with:
Compress the hero image.
Now the order is different.
1. Identify the Actual LCP Element
Do not assume it is the hero image.
Measure it.
2. Break LCP Into Its Components
Look separately at:
- TTFB
- Resource load delay
- Resource load duration
- Element render delay
3. Fix the Largest Delay First
Do not optimize what is easiest.
Optimize what is expensive.
4. Make the LCP Resource Discoverable
If it is an image, the browser should ideally discover it directly from the HTML.
Avoid unnecessary dependency chains.
5. Give the Resource Appropriate Priority
For an important above-the-fold image, consider:
fetchpriority="high"
Do not lazily load it.
6. Send Fewer Bytes
Use:
- Appropriately sized images
- Responsive sources
- Modern formats
- Sensible compression
- Efficient delivery
7. Look at What Happens After Download
A fully downloaded LCP resource can still sit around waiting to render.
Inspect:
- Render-blocking CSS
- Synchronous scripts
- Long tasks
- Client-side rendering
- Hydration
- Third-party JavaScript
8. Validate With Real Users
Your development laptop is not your audience.
Measure the distribution of actual experiences.
Lab Data and Field Data Answer Different Questions
This distinction matters.
During optimization, we wanted to know:
Did this specific change improve the page?
Controlled lab testing is excellent for that.
But after deployment, the question changes:
Are real users actually getting a good experience?
That requires field data.
A single 1.2-second Lighthouse result does not prove that 75% of production users have a 1.2-second LCP.
Real users bring:
different phones
different networks
different locations
different cache states
different viewport sizes
different browser conditions
So we used lab measurements for iteration and field measurements for validation.
That prevented us from confusing a fast benchmark with a fast website.
The Biggest Lesson Was Not “Compress Your Images”
Image optimization mattered.
Caching mattered.
JavaScript mattered.
CSS mattered.
But none of those were the real lesson.
The lesson was:
LCP optimization works better when you debug time instead of applying performance tips.
We started with six seconds.
Then asked where those six seconds were going.
6.0s
├── 1.5s waiting for HTML
├── 2.1s waiting to request the hero
├── 1.0s downloading the hero
└── 1.4s waiting to render it
That immediately gave us a priority order.
By the end:
1.2s
├── 0.5s TTFB
├── 0.2s resource load delay
├── 0.3s resource load duration
└── 0.2s render delay
We did not make the browser magically faster.
We simply stopped making it wait.
Final Takeaway
If your LCP is six seconds, resist the urge to immediately install another optimization plugin, compress every asset, or rewrite the frontend.
Measure first.
Find the LCP element.
Break the metric into its timing components.
Then attack the largest delay.
For us, the sequence was:
6.0s
↓
5.0s Faster server response
↓
3.1s Earlier LCP discovery
↓
2.4s Smaller responsive image
↓
1.5s Less render blocking
↓
1.2s Less main-thread work
Every change had a reason.
Every change had a measurement.
And that is the part of LCP optimization that matters most:
Do not optimize based on assumptions. Optimize based on evidence.

Top comments (0)