Contents
On 22/07/2026, I got the latest PageSpeed Insights results for the marketing365.vn homepage. The mobile version scored 71, much better than before. The desktop version scored 89, but it came with a number I could not make sense of: CLS 0,138. In the previous test, this metric was 0.
CLS measures layout shift: content has already been rendered on the screen, then gets pushed somewhere else. The passing threshold is below 0.1. Jumping from 0 to 0.138 means something on the page started shifting — and I had just touched a bunch of things, so I had no idea which one to blame.
It took four failed hypotheses before I found it. All four were disproved by the evidence I gathered myself. This article records all four wrong turns, because the wrong part is often the most instructive part.
The number that sent me looking
The biggest problem was not the number itself, but the fact that I had just done three closely related things: trimmed the theme CSS file, rebuilt the critical CSS, and changed how background images were loaded. When three changes sit next to each other in the same week, no measurement can separate who caused what.
Experience from the previous two times told me that guessing at this stage wastes a lot of time. I had once cut 80% of the theme CSS file and then sat waiting for LCP to move, only for it not to budge — I wrote about that in Cut 80% of the theme CSS file and LCP still did not budge. So this time I set a rule for myself: every hypothesis had to die by a specific piece of evidence, not by intuition.
Four times I thought I had found the culprit
Hypothesis 1 — two overlapping cookie box systems. I grepped the string consent-bar in the source code and got 10 lines. Ten lines sounded like proof of a mess. When I read each line carefully, they were all cky-consent-bar — the class name of the CookieYes plugin itself, appearing in 10 different places in its CSS. There was no second system. Counting grep hits is not the same as counting components.
Hypothesis 2 — the cookie box was pushing content down. That sounded very plausible: the banner appears, content gets shoved down, the layout shifts. When I opened the plugin CSS, .cky-consent-container declared position: fixed. A fixed element is taken out of the layout flow; it sits on top of the page instead of taking up space. This hypothesis died without a single measurement — just by reading one declaration correctly.
Hypothesis 3 — images were missing width and height declarations. This is a classic CLS cause: the browser does not know how tall an image will be, reserves the wrong space, and when the image loads everything shifts. I scanned the homepage: 77 of the 79 images had both attributes. The two missing ones were plugin icons, and they were also position: fixed. Not enough to produce 0.138.
Hypothesis 4 — swiper and font-awesome were loaded twice. This was the one I felt most confident about, because the HTML clearly showed two identical tags. But the second copy was safely inside <noscript>. That was the intentional fallback of mu-plugins/mna-css-diet.php: the CSS-loading technique using media="print" onload only works when JavaScript is available, so a copy has to be kept inside <noscript> for visitors who disable JS. The duplication here is a feature, not a bug. Someone — namely me, three weeks earlier — had deliberately put it there.

What was left after ruling everything out
Once I crossed out the four wrong ideas, only one thing remained that I had touched but not yet examined: the font. That week I had moved the font off fonts.googleapis.com and self-hosted it in the theme directory, while keeping font-display: swap.
swap means: render the text immediately with whatever font is already available on the client, then replace it once the woff2 file finishes loading. That sounds great — text appears early, with no blank waiting period. The problem is that two typefaces are rarely the same width. The system font is a little narrower, the real font is a little wider, and at the moment of replacement each line of text becomes longer or shorter, the number of lines in a paragraph can change, and every block below gets pushed around. That is exactly the definition of layout shift.
The fix is not to remove swap, but to make the fallback font closer in size to the real one. CSS lets you declare an @font-face that points to a system font and then adjust its metrics:
@font-face {
font-family: 'BVP Fallback';
src: local('Arial'), local('Helvetica'), local('Roboto');
size-adjust: 100.2%;
ascent-override: 99.8%;
descent-override: 26.4%;
line-gap-override: 0%;
}
Those four numbers were not made up. I derived them with fontTools, reading xAvgCharWidth and the hhea table from the two web fonts I was using, then comparing them with Liberation Sans (standing in for Arial) and Liberation Serif (standing in for Georgia). The Playfair Display version produced a different set of values: 102.0% / 106.1% / 24.6%. After that, I inserted the fallback font name into 16 font-family declarations scattered across style.css and home-magazine.css — miss one place, and that place still shifts.
Self-hosting fonts: the easy ways to break it
Moving fonts onto my own server sounds simple, but there are a few places where one mistake wipes out the whole effort:
- The font preload tag must include
crossorigin. Fonts are always fetched in CORS mode. Without this attribute, the preload tag does not match the real request, and the browser downloads the file a second time — slower than if there had been no preload at all. - Keep
unicode-rangewhen splitting character sets. This is what lets the browser skip files it does not need. Remove this line and every client will download all three subsets, even if it only displays Vietnamese. - Only preload what is definitely used above the fold. I preload Be Vietnam Pro 400 on every page, while Playfair Display 700 is only preloaded on the homepage and article pages — the two places where the large headline really sits at the top of the screen. Excess preload competes for bandwidth with what matters more.
Today’s server-side result: 15 self-hosted woff2 files, totaling 279.356 B for Be Vietnam Pro 400/600/700 and Playfair Display 600/700, with three subsets each: vietnamese + latin + latin-ext. I dropped the cyrillic set entirely — this site does not use Russian text. The homepage now outputs 5 font preload tags, all of them with crossorigin.
Verification, even when the numbers go the other way
When I measured again at 22:30 VN time the same day, still using PageSpeed Insights so I could compare it with the previous run: desktop CLS dropped from 0,138 to 0,023, mobile CLS from 0.036 to 0.018. Desktop score rose from 89 to 93 (FCP from 0.8 to 0.5 seconds, LCP from 1.2 to 1.0 seconds). Mobile score rose from 71 to 76 (FCP from 3.0 to 2.0 seconds, LCP from 5.7 to 5.3 seconds).
At that point, the fifth hypothesis was proven: font swap really was the source of the CLS, and the metric-matched fallback eliminated it.
But one number moved in the wrong direction, and I am not hiding it: desktop TBT went from 30 ms to 150 ms. I do not yet know whether that is a real consequence of the change or just noise from a single run — PageSpeed runs on shared machines, and one-off measurements can vary quite a bit. I am recording it, not concluding from it. I should also say plainly: the mobile score of 76 is still not green, and the next investigation led me to something completely different — the homepage was not being cached the way I thought it was.
What I took away from this
Four wrong hypotheses left behind four lessons clearer than the correct one. Counting grep lines is not evidence — ten lines can be the same thing repeated ten times. A single CSS declaration can sometimes kill an entire hypothesis without any measurement: if it is position: fixed, it cannot shift the layout, end of story. And duplication in source code is not always a bug; before removing it, find out who put it there on purpose and why.
The fourth lesson was more expensive: when several changes sit next to each other, the right answer is often the one I did not think of, simply because I had already filed it under “done.” Fonts were something I had just finished and mentally marked as successful, so they stayed off the suspect list for four rounds. The same thing happened when I trusted a tool label and spent 11 days fixing the wrong thing — I wrote about that in 11 days fixing the wrong thing because I trusted a bad label.
There is one more thing I have not cleaned up, and I am writing it down here instead of staying silent. When I checked again today, the word “googleapis” still appears exactly once on the homepage — inside a preconnect tag. Preconnect is a browser hint to open a connection in advance to a server it will soon need to contact. Since I started self-hosting the fonts, there are no requests going there anymore, so that hint now only spends one network handshake on nothing. I am leaving it in place and recording it rather than removing it: removing it is a separate change, and it needs its own measurement to know whether it actually changes anything. If I change things without measuring, I end up right back where I just came from.



