"Server-rendered, jadi pasti cepat" — dan skornya 46
Situs marketing Express + Handlebars. Tidak ada React, tidak ada hidrasi, HTML lengkap datang dari server dalam 180 ms. Secara arsitektur ini sudah menang dibanding SPA. Lighthouse mobile tetap memberi angka 46.
Rinciannya:
LCP 4.6 s (hero image + font blocking)
INP 340 ms (satu handler scroll jQuery)
CLS 0.24 (gambar tanpa dimensi + banner promo)
TBT 810 ms
Ini pola yang berulang. SSR menyelesaikan Time to First Byte dan First Contentful Paint, lalu tiga hal lain merusak sisanya: CSS render-blocking, font yang menahan teks, dan bundle jQuery yang dieksekusi sebelum halaman sempat interaktif. Rendering di server tidak memberi kekebalan apa pun terhadap ketiganya.
Artikel ini menelusuri profiling nyata pada stack Express + Handlebars sampai ketiga metriknya hijau, termasuk angka sebelum-sesudah pada setiap langkah dan regression guard supaya perbaikannya tidak hilang di deploy berikutnya.
Lab data bohong secara berbeda dari field data
Sebelum mengubah apa pun: Lighthouse bukan sumber kebenaran untuk peringkat.
- Lab data (Lighthouse, PageSpeed Insights bagian bawah) — satu perangkat simulasi, jaringan yang di-throttle, tanpa cache. Bagus untuk debugging, karena deterministik dan menunjuk penyebab.
- Field data (CrUX, bagian atas PageSpeed Insights) — persentil ke-75 dari pengunjung Chrome sungguhan selama 28 hari. Ini yang dipakai Google sebagai sinyal.
Konsekuensi praktisnya: INP tidak akan pernah muncul benar di Lighthouse, karena tidak ada yang mengklik apa pun dalam audit otomatis. Lighthouse melaporkan TBT sebagai proksi. Situs bisa TBT 0 dan INP 500 ms sekaligus, kalau masalahnya ada di handler klik menu.
Ambil field data lewat API supaya bisa dibandingkan antar deploy:
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"origin":"https://situskamu.com","formFactor":"PHONE"}' \
| jq '.record.metrics | to_entries[] | {metric: .key, p75: .value.percentiles.p75}'
{"metric":"largest_contentful_paint","p75":4380}
{"metric":"interaction_to_next_paint","p75":312}
{"metric":"cumulative_layout_shift","p75":0.21}
Simpan output ini sebelum optimasi. Field data butuh 28 hari untuk bergerak penuh — tanpa baseline, kamu tidak akan tahu apakah perubahannya berhasil atau kamu cuma sedang melihat variasi trafik.
LCP: tiga sumber, urut dari yang paling sering
Buka waterfall Lighthouse, cari elemen LCP. Pada situs marketing, hampir selalu hero image atau heading di dalam hero.
CSS render-blocking
Ini yang paling khas pada stack Handlebars, karena layout.hbs memuat semua stylesheet untuk semua halaman:
<link rel="stylesheet" href="/stylesheets/index.min.css?v={{assetVersion}}">
<link rel="stylesheet" href="/stylesheets/tailwind.css?v={{assetVersion}}">
Dua permintaan yang keduanya menahan render. Ukur dulu, jangan menebak:
curl -sI -H 'Accept-Encoding: gzip,br' https://situskamu.com/stylesheets/index.min.css \
| grep -iE 'content-length|content-encoding|cache-control'
content-encoding: br
content-length: 24180
cache-control: public, max-age=31536000, immutable
24 KB terkompresi, dan sudah immutable — untuk pengunjung berulang ini gratis. Untuk kunjungan pertama di 4G, dua file ini menahan render sekitar 300–400 ms.
Yang berhasil, berurutan dari dampak terbesar:
Inline critical CSS untuk viewport pertama. Ekstrak sekali saat build, simpan sebagai partial, dan muat sisanya secara non-blocking:
<style>{{{criticalCss}}}</style>
<link rel="stylesheet" href="/stylesheets/index.min.css?v={{assetVersion}}"
media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/stylesheets/index.min.css?v={{assetVersion}}"></noscript>
Trik media="print" membuat browser tetap mengunduh dengan prioritas rendah tanpa menahan render, lalu onload mempromosikannya. <noscript> menjaga halaman tetap bergaya kalau JS mati.
Batasnya: jaga critical CSS di bawah ~14 KB. Lebih dari itu, HTML-nya sendiri butuh lebih dari satu round-trip TCP awal dan kamu memindahkan masalah, bukan menghapusnya.
Preload font, dan jangan biarkan teks menunggu. Font yang di-@font-face baru ditemukan browser setelah CSS terurai — dua level kedalaman sebelum unduhan dimulai:
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-display: swap; /* teks tampil dengan fallback, bukan invisible selama 3 detik */
font-weight: 100 900;
}
Atribut crossorigin wajib ada walau file-nya same-origin. Tanpa itu, browser menganggap preload dan permintaan font sebagai dua permintaan berbeda dan mengunduhnya dua kali — kamu menambah beban sambil merasa sedang mengoptimasi. Cek di DevTools Network: kalau nama font muncul dua baris, ini penyebabnya.
Hero image: prioritas, format, dan ukuran.
<img src="/uploads/hero-1200.webp"
srcset="/uploads/hero-640.webp 640w, /uploads/hero-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 1200px"
width="1200" height="675"
fetchpriority="high" decoding="async" alt="{{sections.masthead.alt}}">
loading="lazy" pada hero adalah kesalahan yang sering terjadi karena lazy-loading dipasang massal ke semua <img>. Pada elemen LCP, itu justru menunda unduhan sampai layout selesai. Aturannya: lazy untuk semua gambar di bawah lipatan, fetchpriority="high" untuk yang di atasnya.
Hasil gabungan ketiganya pada situs contoh:
| Perubahan | LCP mobile |
|---|---|
| Baseline | 4,6 s |
+ preload font + font-display: swap | 3,9 s |
+ hero WebP 1200px + fetchpriority | 2,8 s |
| + critical CSS inline | 2,1 s |
INP: satu handler jQuery bisa merusak seluruh situs
INP mengukur jeda dari interaksi sampai frame berikutnya tergambar, pada persentil ke-75 dari semua interaksi. Ambangnya 200 ms.
Penyebab paling umum pada situs jQuery bukan handler yang berat secara komputasi, tapi handler yang membaca layout di dalam loop:
// Sebelum — 340 ms per scroll, memaksa layout berulang
$(window).on('scroll', function () {
$('.animate-on-scroll').each(function () {
if ($(this).offset().top < $(window).scrollTop() + $(window).height()) {
$(this).addClass('visible');
}
});
});
Tiga masalah sekaligus: handler jalan pada setiap event scroll (bisa 60×/detik), .offset() memaksa reflow sinkron, dan addClass di tengah loop membatalkan layout untuk iterasi berikutnya — layout thrashing klasik. Dengan 40 elemen, ini 40 reflow paksa per event.
Penggantinya tidak butuh library dan mengembalikan pekerjaan itu ke browser:
// Sesudah — 0 ms di main thread saat scroll
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add('visible');
io.unobserve(e.target); // sekali tampil, berhenti mengamati
}
}, { rootMargin: '0px 0px -10% 0px' });
document.querySelectorAll('.animate-on-scroll').forEach((el) => io.observe(el));
Untuk handler yang memang harus mengerjakan sesuatu yang lama, pecah agar paint terjadi lebih dulu:
btn.addEventListener('click', () => {
showSpinner(); // umpan balik visual: frame ini
requestAnimationFrame(() => setTimeout(() => {
hitungUlangTabelBesar(); // pekerjaan berat: setelah paint
hideSpinner();
}, 0));
});
INP diukur sampai frame berikutnya, bukan sampai pekerjaan selesai. Menampilkan spinner lebih dulu bukan tipuan — dari sudut pandang pengguna, itu memang respons yang benar.
Ukur INP sungguhan di lapangan, karena Lighthouse tidak bisa:
<script type="module">
import { onINP, onLCP, onCLS } from 'https://unpkg.com/web-vitals@4?module';
const send = (m) => navigator.sendBeacon('/services/vitals',
JSON.stringify({ name: m.name, value: m.value, id: m.id, path: location.pathname }));
onINP(send); onLCP(send); onCLS(send);
</script>
Endpoint-nya sepuluh baris di service router, menulis ke tabel yang sama dengan analytics. sendBeacon bertahan saat halaman ditutup — fetch biasa akan dibatalkan.
Dengan data itu, onINP melaporkan attribution.interactionTarget — selector elemen yang lambat. Kamu berhenti menebak handler mana yang bersalah.
CLS: ruang harus dipesan sebelum konten datang
CLS pada situs server-rendered hampir selalu berasal dari empat sumber, dan semuanya bisa diperbaiki di template.
Gambar tanpa dimensi. Browser modern menghitung ruang dari width dan height sebagai rasio, jadi atribut itu tetap relevan walau CSS-nya responsif:
img { max-width: 100%; height: auto; } /* pasangan wajib dari atribut width/height */
Kalau dimensinya tidak diketahui saat render — misalnya thumbnail hasil upload — simpan dimensinya ke database saat file masuk. Satu kolom width dan height di tabel uploads menghapus seluruh kelas masalah ini.
Banner promo yang disisipkan JavaScript. Pesan tempatnya di markup, jangan biarkan tingginya muncul belakangan:
.promo-slot { min-height: 72px; }
@media (min-width: 768px) { .promo-slot { min-height: 56px; } }
Font swap yang menggeser teks. font-display: swap menukar fallback dengan font asli, dan kalau metriknya jauh berbeda, paragraf bergeser. Perbaikannya sudah didukung semua browser utama:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
body { font-family: 'Inter', 'Inter Fallback', sans-serif; }
Konten CMS. Kalau seksi homepage bisa diedit, tinggi elemennya berubah mengikuti panjang teks. Batasi jumlah karakter di validator CMS, bukan di CSS — memotong dengan overflow: hidden menyembunyikan gejala sambil tetap menggeser layout.
Regression guard: menjaga hasilnya tetap ada
Optimasi tanpa pagar akan hilang dalam tiga sprint. Satu orang menambah widget chat, satu lagi menambah font kedua, dan angka kembali seperti semula.
Lighthouse CI, dijalankan pada setiap push:
// lighthouserc.json
{
"ci": {
"collect": { "url": ["http://localhost:3000/", "http://localhost:3000/blog"], "numberOfRuns": 3 },
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 200 }],
"unused-css-rules": ["warn", { "maxLength": 2 }]
}
}
}
}
- run: npm run build && npm start & npx wait-on http://localhost:3000
- run: npx @lhci/cli autorun
numberOfRuns: 3 bukan pemborosan — Lighthouse pada CI runner bervariasi cukup lebar, dan satu run akan menghasilkan kegagalan acak yang membuat tim mengabaikan pagar ini.
Pagar kedua, jauh lebih murah, untuk ukuran aset:
BUNDLE=$(gzip -c public/javascripts/bundle.min.js | wc -c)
[ "$BUNDLE" -lt 92160 ] || { echo "bundle > 90 KB gzip: $BUNDLE"; exit 1; }
Anggaran eksplisit mengubah percakapan "apakah library ini berat" dari selera menjadi angka.
Urutan yang direkomendasikan
Kalau kamu mulai hari ini, kerjakan dalam urutan ini — dampak per jam kerja menurun ke bawah:
- Catat baseline CrUX p75 lewat API di atas. Tanpa ini kamu tidak bisa membuktikan apa pun.
- Perbaiki hero: dimensi eksplisit, WebP,
fetchpriority="high", dan pastikan tidak adaloading="lazy"di atas lipatan. - Preload font dengan
crossorigin+font-display: swap. Verifikasi di Network bahwa font hanya terunduh sekali. - Cari handler scroll/resize di bundle:
grep -n "on('scroll\|on('resize" public/javascripts/*.js. Ganti dengan IntersectionObserver. - Beri
min-heightpada setiap slot yang diisi JavaScript. - Pasang
web-vitals+ endpoint beacon supaya INP bisa dilihat, bukan ditebak. - Baru setelah itu, inline critical CSS — pekerjaan paling rumit dengan hasil paling kecil dari daftar ini.
Empat langkah pertama biasanya sudah memindahkan LCP dari merah ke hijau dalam satu hari kerja.



