ผมเพิ่งผ่านกระบวนการแก้ Performance Score ของเว็บตัวเองมาสดๆ ร้อนๆ เลยครับ (kittipong.dev) แล้วคิดว่าอยากจะบันทึกเอาไว้หน่อย เผื่อว่าจะมีประโยชน์ครับ
บทความนี้ไม่ได้บอกทฤษฎี แต่จะเล่าว่าเจอปัญหาอะไร วิเคราะห์ยังไง และแก้ด้วยวิธีไหน — เผื่อใครกำลังเผชิญกับปัญหาเดียวกัน
ปัญหาที่ 1: GTM บล็อก FCP และ LCP
วิเคราะห์ปัญหา
เมื่อดู Lighthouse พบว่า FCP (First Contentful Paint) ช้ากว่าที่ควรจะเป็น ทั้งที่ไม่มีรูปใหญ่หรือ JavaScript หนักๆเลย
พอลองตรวจสอบก็พบว่าตัวการคือ Google Tag Manager ที่โหลดแบบ synchronous อยู่ใน <head> ตามวิธีติดตั้งมาตรฐานที่ Google แนะนำนี่เองครับ
<!-- วิธีติดตั้งแบบมาตรฐาน (ช้า) -->
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');
</script>
แม้ว่า script จะมี async แต่การที่มันอยู่ใน <head> และทำการเชื่อมต่อไป googletagmanager.com ทันที ยังทำให้ browser ต้องเสียเวลา DNS lookup + TCP connection ก่อนที่จะ render เนื้อหาหน้าเว็บได้
วิธีแก้: Lazy GTM
เปลี่ยนให้ GTM โหลดเมื่อผู้ใช้ทำ interaction ครั้งแรก (scroll, click, touch, keydown) หรือหลังจาก 5 วินาที ระหว่างนั้น window.dataLayer = [] ไว้รับ push ก่อน event จริงจะส่งเมื่อ GTM โหลดเสร็จ
<script>
window.dataLayer = window.dataLayer || [];
(function(){
var done = false;
function load() {
if (done) return; done = true;
window.dataLayer.push({'gtm.start': new Date().getTime(), event: 'gtm.js'});
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX';
document.head.appendChild(s);
}
['scroll','click','touchstart','keydown'].forEach(function(e){
window.addEventListener(e, load, {once: true, passive: true});
});
setTimeout(load, 5000);
})();
</script>
Tradeoff ที่ต้องรู้: GTM จะโหลดก็ต่อเมื่อผู้ใช้ scroll / click / แตะหน้าจอ หรือหลังจาก 5 วินาที — ดังนั้น ถ้า user เปิดหน้าแล้วออกทันทีโดยไม่ทำอะไรและภายใน 5 วินาที GTM จะไม่โหลดเลย = ไม่มี pageview ส่งไป GA4 สำหรับ session นั้น
ปัญหาที่ 2: CSS background-image ไม่มี Preload
วิเคราะห์ปัญหา
หน้า /blog/ มี hero banner ที่ใช้ CSS background-image ซึ่งมีปัญหาที่น่าสนใจ คือ Browser ค้นพบ <img src="..."> ได้ตั้งแต่ตอน parse HTML แต่สำหรับ CSS background-image: url(...) browser ต้องรอให้
- โหลด HTML เสร็จ
- โหลด CSS เสร็จ
- Parse CSS เสร็จ
- จึงจะรู้ว่ามีรูปที่ต้องโหลด
นั่นคือ CSS background ถูก “ค้นพบ” ช้ากว่า <img> มาก ผลคือ LCP ช้าเพราะรูป hero โหลดช้า
วิธีแก้: <link rel="preload">
บอก browser ตั้งแต่ต้นว่ามีรูปสำคัญที่ต้องโหลด โดยใส่ใน <head>:
<!-- Desktop -->
<link rel="preload" as="image" href="/hero.webp"
media="(min-width: 761px)" fetchpriority="high" />
<!-- Mobile -->
<link rel="preload" as="image" href="/hero-mobile.webp"
media="(max-width: 760px)" fetchpriority="high" />
ใช้ media attribute แยก desktop/mobile เพื่อไม่ให้โหลดทั้งสองรูปพร้อมกัน และต้องมี CSS media query ให้ตรงกันด้วย:
.page-header {
background-image: url('/hero.webp');
}
@media (max-width: 760px) {
.page-header {
background-image: url('/hero-mobile.webp');
}
}
ปัญหาที่ 3: CLS 0.269 บนหน้าบทความ
วิเคราะห์ปัญหา
CLS (Cumulative Layout Shift) 0.269 ถือว่าแย่มากครับ (เกณฑ์ “ดี” คือ ≤ 0.1) ตอนแรกคิดว่าเป็นที่รูป พอดูโค้ดแล้วทุกรูปก็มี aspect-ratio ถูกต้อง ทีนี้เลยลองดูรอบๆว่ามีอะไรที่น่าจะทำให้หน้าเวบมันขยับได้อีกมั้ย ก็เลยคิดว่าน่าจะเป็นที่ Fonts แล้วก็ใช่จริงๆครับ ปัญหาคือ Google Fonts font-display: swap
กลไกคือ:
- Browser render หน้าด้วย system font ก่อน
- Google Fonts โหลดมาทีหลัง (บน slow 4G อาจใช้เวลา 1-3 วินาที)
- Browser “swap” font จาก system font → web font
- ขนาดตัวอักษรต่างกัน → text reflow → layout เลื่อน → CLS
วิธีแก้: font-display: optional
/* เปลี่ยนจาก */
&display=swap
/* เป็น */
&display=optional
ความต่างคือ optional ให้เวลา browser โหลด font แค่ ~100ms เท่านั้น ถ้าโหลดไม่ทัน browser ใช้ system font ถาวรสำหรับ page load นั้น — ไม่มี swap = ไม่มี reflow = CLS → 0
Tradeoff: User บน WiFi/4G จะเห็น web font (โหลดทัน 100ms) แต่ user บน slow 4G จะเห็น system font แทน — ซึ่งอ่านได้ปกติครับแค่เป็น system font
ปัญหาที่ 4: Google Fonts DNS Overhead
วิเคราะห์ปัญหา
แม้จะใช้ font-display: optional แล้ว ยังมี overhead ที่ซ่อนอยู่ คือทุก page load browser ต้องทำ:
- DNS lookup →
fonts.googleapis.com - TCP connection →
fonts.googleapis.com - โหลด font CSS
- DNS lookup →
fonts.gstatic.com(ที่เก็บไฟล์ font จริง) - TCP connection →
fonts.gstatic.com - โหลด woff2 files
บน slow 4G แต่ละ DNS lookup + TCP handshake ต้องใช้ ~100-300ms และต้องทำ 2 domain = ประมาณ 400-600ms ที่หายไปก่อน browser จะรู้ว่า font ไฟล์อยู่ที่ไหน
วิธีแก้: Self-host Fonts
ดาวน์โหลด woff2 มาเก็บที่ origin เดียวกันกับเว็บ:
# script ดาวน์โหลด font files จาก Google Fonts
node scripts/download-fonts.mjs
# → public/fonts/*.woff2
# → src/styles/fonts.css (@font-face ชี้ไป /fonts/)
แล้วลบ Google Fonts links ออกจาก <head> ทั้งหมด:
<!-- ลบออก -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?..." />
แทนด้วย import ปกติใน CSS:
/* src/styles/fonts.css — generate โดย script */
@font-face {
font-family: 'DM Sans';
font-weight: 400;
src: url(/fonts/xxx.woff2) format('woff2');
unicode-range: U+0000-00FF, ...;
}
/* ... */
สุดท้าย Responsive Images กับ WebP
สิ่งที่ทำควบคู่กันตลอดคือเรื่องรูป ซึ่งมีหลักการง่ายๆ สองข้อ:
หลักการที่ 1: ไม่ส่งรูปใหญ่ให้มือถือ
article cover ทุกรูปต้องมีครบ 4 ไฟล์:
| ไฟล์ | ขนาด | ใช้ที่ไหน |
|---|---|---|
cover.webp | 2560px | Desktop hero |
cover-md.webp | 1280px | Mobile hero (ผ่าน srcset) |
cover-thumb.webp | 768px | รูปย่อในหน้ารวมบทความ |
cover-og.jpg | full, quality 86 | og:image โซเชียล (ต้องเป็น jpg) |
<!-- browser เลือกขนาดให้อัตโนมัติ -->
<img
srcset="/cover-md.webp 1280w, /cover.webp 2560w"
sizes="(max-width: 760px) 100vw, 1032px"
src="/cover.webp"
loading="eager"
fetchpriority="high"
/>
มือถือ (viewport ≤ 760px) จะโหลด cover-md.webp (1280px) แทน cover.webp (2560px) — ประหยัดได้มหาศาลบน slow 4G
หลักการที่ 2: ใช้ webp เสมอ (ยกเว้น og:image)
WebP ขนาดเล็กกว่า JPEG ที่คุณภาพเดียวกัน 25-35% และทุก browser สมัยนี้รองรับแล้ว ยกเว้น og:image ต้องเป็น .jpg เพราะ crawler ของ LINE และ Facebook บางครั้งรองรับ webp ไม่แน่นอน
สรุป
ทาดาาาา!!! 🎉🥳 ได้ 100 คะแนนเต็ม เย้ๆๆ ✨🚀

เรื่อง Performance ส่วนใหญ่ไม่ได้ซับซ้อนอะไรมากครับ จริงๆก็ขึ้นอยู่กับเว็บด้วยอะนะ แต่ทุกปัญหา ถ้าเรารู้ถึงสาเหตุจริงๆ มันมีทางแก้เสมอครับ
แหล่งอ้างอิง
- web.dev — First Contentful Paint (FCP) https://web.dev/articles/fcp
- web.dev — Largest Contentful Paint (LCP) https://web.dev/articles/lcp
- web.dev — Cumulative Layout Shift (CLS) https://web.dev/articles/cls
- web.dev — Optimizing loading third-party JavaScript https://web.dev/articles/optimizing-content-efficiency-loading-third-party-javascript