dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

LCP of 6.4 seconds caused by an overlay nobody saw

In short: a walkthrough of our own site. The text of the first screen arrived from the server in 10 milliseconds and appeared on screen six and a half seconds later — hidden behind an intro overlay that only loaded JavaScript could remove. After the fix, LCP went 6.4 → 2.2 s and the performance score 73 → 98. Below is how we found it, including the hypothesis that turned out to be wrong.

8 min read

In short

  • Ready HTML guarantees nothing: LCP is measured at paint, not at delivery.
  • The expensive mistake was not the animation but the fact that the “should we show the intro” condition lived in a React hook — that is, became known only after hydration.
  • The first hypothesis (framer-motion’s initial styles in the markup) was true but secondary: on its own it changed nothing.
  • Result on the same build: LCP 6.4 → 2.2 s, Speed Index 4.9 → 1.9 s, score 73 → 98.

How it started

Loading speed is the one thing you must not economise on when building a website, and we tell clients so. Which is why we started with ourselves: we ran Lighthouse against our own site — not a client’s, our own. The mobile performance score came back at 76 with almost perfect everything else: time to first byte half a second, main-thread blocking 60 ms, layout shift 0.001. And LCP of 7.0 seconds.

That combination is odd. A bad LCP usually travels with a bad TBT: heavy JavaScript strangles both. Here the main thread was free, there are no images in the first screen, and fonts loaded with swap. The LCP breakdown explained everything in one line: time to first byte 0.55 s, the remaining 6.4 seconds were element render delay. The element had been delivered and not painted.

The first hypothesis — and why it failed

The markup of the first screen turned out to contain 95 elements with opacity: 0, including the LCP element itself — the paragraph beneath the heading. The cause was clear: framer-motion writes the initial animation state straight into the style attribute during server rendering. Until React loads and starts the animation, the text is physically invisible.

html
<p class="max-w-xl text-lg leading-relaxed text-ink-dim"
   style="opacity:0;transform:translateY(20px)">
  From landing pages to online banking — one partner for the whole cycle…
</p>
This is what the LCP element looked like in the HTML delivered by the server. The text is there — and cannot be seen.

The explanation looked complete, so we rewrote the first-screen animation in CSS: same keyframes, same curve, same duration — but starting on the first painted frame rather than after hydration. Built it, measured it.

Nothing changed. LCP was 5.0 s on the local build before and 5.0 s after. That is the heart of this walkthrough: a plausible explanation and a correct one are different things, and only a measurement tells them apart.

A hypothesis that explains the symptom but does not disappear along with it after the fix is a coincidence, not a cause.

The actual cause

Next we stopped looking at the styles of the LCP element and looked at what sits above it. The markup contained the intro overlay — the one with the counting numbers that sets the mood on desktop:

html
<div class="fixed inset-0 z-[1000] flex flex-col justify-between bg-base p-6">
  …  <!-- 000  ·  booting  ·  Loading experience -->
</div>

An opaque full-screen layer on top of everything. On a phone the intro should not be shown, and the code knew that — the condition was evaluated by a useCanRender3D() hook: desktop, fine pointer, enough cores and memory. The problem is when that condition becomes known. A hook is React, React is hydration. Until then the server does not know who is on the other end, and renders the overlay for everybody.

BeforeHTML readyJS download and parseHydration6.4sAfter2.2s · Text visibleThe text was in the markup all along — behind the overlay
The text of the first screen reached the browser almost immediately. It spent the rest of the time underneath an opaque layer that only loaded, executed JavaScript could remove.

The result was a closed loop: removing the overlay requires JS; until JS arrives, the visitor stares at an empty dark screen even though all the content is already on their device. It also explained why the first fix did nothing: the transparency of the paragraph was irrelevant while something covered it from above.

The fix

The answer is to move the condition out of JavaScript and into CSS. A media query is evaluated during the very first style calculation, before any React, and on a phone the overlay simply never exists as a painted layer.

css
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
  .intro-gate {
    display: none !important;
  }
}
The same condition as in the hook. The only difference is that it applies from the first frame.

The second half is that CSS rewrite of the first-screen animation. On its own it did nothing, but once the overlay was gone it became mandatory: remove the top layer and the very next thing you hit is opacity: 0. Two mistakes had been masking each other.

What actually changed
  1. 01

    The overlay is hidden by a media query

    The layer stopped being painted on devices where the intro is not shown — regardless of whether JavaScript has loaded.

  2. 02

    The first-screen animation moved to CSS

    Same keyframes, durations and curve. The only difference is when it starts: the first painted frame instead of the end of hydration.

  3. 03

    Nothing was removed

    On desktop the intro runs exactly as before. That matters: a signature animation is part of the product, not the thing you pay a metric with.

The result

Measured on one machine, on identical code, with real throttling (Lighthouse, mobile profile, --throttling-method=devtools) against a local production build — to remove the network and the variance from the picture.

Metric
Before → after
Performance score
73 → 98
Largest Contentful Paint
6.4 s → 2.2 s
Speed Index
4.9 s → 1.9 s
First Contentful Paint
1.8 s → 1.6 s
Element render delay
6394 ms → 2186 ms

How to check this on your own site

Five minutes, and no tools beyond a browser
  • Open the page source (view-source, not the inspector — the inspector shows the state after JS) and find your heading text. If it is not there, LCP is a premature conversation: server rendering comes first.
  • In that same HTML, search for opacity:0, visibility:hidden and transform: translate near first-screen content. All of it is invisible text.
  • Look for elements with position: fixed and a high z-index that always render and are hidden by a condition from JS. Splash screens, preloaders, consent banners.
  • Run Lighthouse with --throttling-method=devtools and read the LCP breakdown. A large element render delay with a small TTFB is your case.
  • Check whether the visibility condition for anything large lives in a React hook. Anything a media query can decide should be decided by a media query.

The general rule we took away: anything that covers content must be able to disappear without JavaScript. If only a script can lift the layer, then the time it takes that script to load becomes the time it takes your content to appear — with everything that follows for user behaviour and for ranking.

Frequently asked questions

Why not simply remove the intro altogether?

Because on desktop it works: it is part of the impression the product makes, and we do not think paying with the product for a metric is right. The task was never “remove the animation” but “stop it interfering with the people it is not shown to”. After the fix, nothing changed on desktop.

Is this a Next.js problem or a framer-motion problem?

Neither. Both tools do exactly what they promise. The mistake is architectural: a condition that controls content visibility was only available after hydration. This repeats on any server-rendered stack — React, Vue, Svelte alike.

How much does this actually matter for the business?

LCP is one of the three Core Web Vitals, and those are ranking signals measured on real users, mostly mobile ones. But before search enters the picture: six seconds of dark screen means people who went back to the results page without seeing a single line. The money is lost before the rankings are.

Why publish a walkthrough of your own mistake?

Two reasons. First, this mistake is easy to repeat and we could not find it written up anywhere, so the piece is useful. Second, a walkthrough of your own miss with numbers can be verified in five minutes: open our site and run Lighthouse yourself. A case study you can check is worth more than one you have to take on faith.

Sources

Every claim here can be checked: below are the primary sources, not a retelling.

  1. 01web.dev — Largest Contentful Paint (LCP)the definition of the metric and its breakdown, including element render delay
  2. 02Chrome for Developers — Lighthouse performance scoringhow the score is computed and how the throttling modes differ
dbit.one engineering desk
The engineers who build these systems

These articles are written by engineers working on the projects — but they are not signed by name. The reason is the same one that keeps client logos off this site: nearly every project runs under an NDA or white-label, and a byline on a piece about a payment core points at the client as clearly as a logo would. Instead of names, we stand behind the text with rules — except for articles about our own open-source code, which carry the author’s name.

How we write and what we verify

Related services

Read next

Questions about your project?

Describe your task — within 24 hours we’ll come back with an estimate, timeline and plan.

[email protected]