ผมเพิ่งผ่านกระบวนการแก้ 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 ต้องรอให้

  1. โหลด HTML เสร็จ
  2. โหลด CSS เสร็จ
  3. Parse CSS เสร็จ
  4. จึงจะรู้ว่ามีรูปที่ต้องโหลด

นั่นคือ CSS background ถูก “ค้นพบ” ช้ากว่า <img> มาก ผลคือ LCP ช้าเพราะรูป hero โหลดช้า

บอก 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

กลไกคือ:

  1. Browser render หน้าด้วย system font ก่อน
  2. Google Fonts โหลดมาทีหลัง (บน slow 4G อาจใช้เวลา 1-3 วินาที)
  3. Browser “swap” font จาก system font → web font
  4. ขนาดตัวอักษรต่างกัน → 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 ต้องทำ:

  1. DNS lookup → fonts.googleapis.com
  2. TCP connection → fonts.googleapis.com
  3. โหลด font CSS
  4. DNS lookup → fonts.gstatic.com (ที่เก็บไฟล์ font จริง)
  5. TCP connection → fonts.gstatic.com
  6. โหลด 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.webp2560pxDesktop hero
cover-md.webp1280pxMobile hero (ผ่าน srcset)
cover-thumb.webp768pxรูปย่อในหน้ารวมบทความ
cover-og.jpgfull, quality 86og: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 คะแนนเต็ม เย้ๆๆ ✨🚀 PageSpeed Insights คะแนน 100 ทุก category บน Mobile

เรื่อง Performance ส่วนใหญ่ไม่ได้ซับซ้อนอะไรมากครับ จริงๆก็ขึ้นอยู่กับเว็บด้วยอะนะ แต่ทุกปัญหา ถ้าเรารู้ถึงสาเหตุจริงๆ มันมีทางแก้เสมอครับ


แหล่งอ้างอิง