- bfcache ทำให้การกด Back/Forward คืนหน้าเว็บได้ในเวลา <100ms ส่งผลให้ LCP และ INP เกือบเป็นศูนย์
- สาเหตุที่พบบ่อยที่สุดที่หน้าไม่เข้า bfcache คือ
Cache-Control: no-store และการใช้ event unload
- ใช้
PerformanceNavigationTiming.notRestoredReasons ใน Chrome เพื่อตรวจสาเหตุที่หน้าถูกบล็อก
- WebSocket, IndexedDB transactions ที่เปิดค้าง และ
beforeunload handler สามารถทำให้หน้าหลุดจาก bfcache ได้
- Safari 17+ และ Firefox 126+ รองรับ bfcache แต่มีเงื่อนไขเข้มงวดกว่า Chrome
- วัด bfcache hit rate ด้วย
pageshow event ที่มี event.persisted === true
bfcache คืออะไรและทำงานอย่างไร
เอาล่ะ ก่อนจะลงรายละเอียดทางเทคนิค มาทำความเข้าใจกันก่อนว่า bfcache (ย่อจาก back/forward cache) เป็นกลไกระดับเบราว์เซอร์ที่เก็บภาพรวมของหน้าเว็บทั้งหมด ทั้ง DOM tree, JavaScript heap, scroll position และสถานะของ tab ไว้ในหน่วยความจำหลังจากผู้ใช้นำทางออกไป เมื่อผู้ใช้กดปุ่ม Back หรือ Forward เบราว์เซอร์ไม่จำเป็นต้องดาวน์โหลด HTML ใหม่ ไม่ต้อง parse JavaScript และไม่ต้องสร้าง render tree ใหม่ มันเพียงคืน snapshot กลับมาแล้วยิง event pageshow เท่านั้นเอง
ในทางเทคนิค Chrome เก็บ Renderer Process ที่หน้าเว็บใช้อยู่ไว้ในสถานะ frozen แทนที่จะทำลายทิ้ง ซึ่งหมายความว่า JavaScript timers จะถูกหยุดชั่วคราว, network connections จะถูก suspend, และ event loop ถูกระงับ พอผู้ใช้กลับมา process จะถูก "ปลุก" และ resume ทันที
ความแตกต่างจาก HTTP cache คือ HTTP cache เก็บแค่ไฟล์ตอบกลับ (response bytes) ซึ่งยังต้อง re-parse และ re-execute ใหม่ทั้งหมด ขณะที่ bfcache เก็บ หน้าเว็บที่ทำงานเสร็จแล้ว ผลที่ได้คือการกลับมาเป็น instant navigation จริง ๆ ข้อมูล CrUX เดือนพฤษภาคม 2026 ระบุว่าไซต์ที่เข้า bfcache ได้มี LCP เฉลี่ยของการนำทางย้อนกลับเพียง 40ms เทียบกับ 2,100ms ของการ reload เต็มรูปแบบ ห่างกันหลายสิบเท่าตัว
วิธีวัด bfcache hit rate ในเว็บไซต์ของคุณ
ก่อนจะแก้ไขอะไร คุณต้องวัดก่อนว่าหน้าเว็บของคุณเข้า bfcache บ่อยแค่ไหน วิธีที่ง่ายและแม่นยำที่สุดคือใช้ event pageshow ซึ่งจะถูกยิงทุกครั้งที่หน้าเว็บแสดงผล โดย property event.persisted จะเป็น true เฉพาะเมื่อหน้านั้นคืนจาก bfcache
// วัด bfcache hit rate และส่งไปยัง analytics
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// หน้านี้คืนจาก bfcache - การนำทางทันที
sendAnalytics('bfcache_hit', {
url: location.pathname,
navigationType: 'back_forward',
});
} else {
// โหลดปกติ (cold load หรือ reload)
sendAnalytics('bfcache_miss', {
url: location.pathname,
});
}
});
// ตรวจสอบประเภทการนำทางอย่างละเอียด
const navEntry = performance.getEntriesByType('navigation')[0];
if (navEntry?.type === 'back_forward') {
console.log('Navigation type: back/forward');
}
หากคุณใช้ web-vitals library (v4+) ก็มี API ภายในสำหรับติดตาม bfcache restoration อยู่แล้ว ตั้งค่า reportAllChanges: true เพื่อให้ INP ถูก report ซ้ำหลังจาก bfcache restoration ด้วย ดูเพิ่มในบทความ เพิ่มประสิทธิภาพ INP ปี 2026 สำหรับการตั้งค่า web-vitals ที่ละเอียดกว่านี้
ทำไมหน้าเว็บไม่เข้า bfcache
หน้าเว็บจะ ไม่ ถูกเก็บใน bfcache เมื่อมีพฤติกรรมที่ขัดกับสมมติฐานเรื่อง frozen state เช่น มี connection ที่ต้องการการ cleanup อย่างเหมาะสม, ใช้ API ที่ระบุชัดว่าไม่ปลอดภัยที่จะ freeze, หรือเซิร์ฟเวอร์ส่ง header ที่ห้าม cache แบบเด็ดขาด นี่คือรายการ blocker ที่พบบ่อยที่สุดในปี 2026 จัดเรียงตามความถี่ที่ทีม Chrome รายงาน
Cache-Control: no-store header ตัวเดียวที่กระทบมากที่สุด พบใน 35% ของหน้าที่ไม่เข้า bfcache
- event
unload การใช้ window.addEventListener('unload', ...) ใน Chrome 126+ จะบล็อก bfcache โดยอัตโนมัติ
- การเปิด WebSocket หรือ WebRTC connection connection ที่ยังเปิดอยู่ทำให้หน้าหลุด eligibility
- การใช้
beforeunload handler บน desktop บน mobile ไม่บล็อก แต่ desktop Chrome บล็อก
- IndexedDB transaction ที่ยังไม่ commit transaction ที่ค้างอยู่จะทำให้เบราว์เซอร์ไม่ freeze หน้า
- Sensors API ที่ active เช่น geolocation watch, device orientation
- Header
Cache-Control: no-cache ในบางเงื่อนไข (ไม่บล็อกเสมอแต่ลด eligibility)
- ขนาด JavaScript heap เกิน 60MB เบราว์เซอร์จะ evict หน้าที่กินหน่วยความจำมาก
ที่น่าสนใจคือจากการสำรวจของทีม Web Performance ของ Google ในไตรมาส 1/2026 พบว่า 70% ของไซต์ที่เปิด bfcache ไม่ได้ มีต้นเหตุจากเพียง 3 ข้อแรก ตอนผมไล่แก้ในไซต์ลูกค้าจริงครั้งล่าสุด แค่จัดการ no-store กับ unload เพียงสองตัว ก็ทำให้ hit rate กระโดดจาก 0% เป็น 58% ภายในวันเดียวเลยครับ
NotRestoredReasons API ปี 2026
NotRestoredReasons API เป็น API ใหม่ที่ Chrome เปิดให้ใช้งานเสถียรตั้งแต่เวอร์ชัน 117 (และ Safari Tech Preview รองรับใน 2026) ช่วยให้นักพัฒนาเห็น เหตุผลที่แท้จริง ว่าทำไมหน้าเว็บถึงไม่ถูกคืนจาก bfcache โดยไม่ต้องเดา
// ตรวจสอบเหตุผลที่หน้าเว็บไม่เข้า bfcache
window.addEventListener('pageshow', (event) => {
// API นี้ใช้ได้กับทั้ง cache hit และ miss
const navEntry = performance.getEntriesByType('navigation')[0];
if (navEntry?.notRestoredReasons) {
const reasons = navEntry.notRestoredReasons;
console.log('bfcache blockers:', reasons);
// ส่งไปยัง analytics เพื่อวิเคราะห์ภาพรวม
if (reasons.reasons && reasons.reasons.length > 0) {
reasons.reasons.forEach((reason) => {
sendAnalytics('bfcache_blocker', {
reason: reason.reason,
url: location.pathname,
});
});
}
// ตรวจสอบ blocker ใน iframe
if (reasons.children) {
reasons.children.forEach((child) => {
console.log(`iframe ${child.src} blocked:`, child.reasons);
});
}
}
});
ค่าที่ reason.reason ส่งกลับเป็น string ที่อ่านง่าย เช่น "unload-handler", "response-cache-control-no-store", "websocket", "masked" (เมื่อสาเหตุมาจาก cross-origin iframe ที่อ่านไม่ได้) รายการเต็มทั้ง 50+ ค่ามีในเอกสาร Chrome
แก้ไข blocker ที่พบบ่อย
1. แก้ไข Cache-Control: no-store
ตรวจสอบ HTTP response headers ของหน้าหลักก่อน บางครั้ง CMS หรือ middleware (เช่น authentication layer) ส่ง no-store โดยไม่จำเป็น เปลี่ยนเป็นแบบนี้
// Express.js - เปลี่ยน no-store เป็น must-revalidate
app.use((req, res, next) => {
if (req.path.startsWith('/account')) {
// เนื้อหาส่วนตัว: ใช้ no-cache แทน no-store
res.setHeader('Cache-Control', 'private, no-cache, max-age=0');
} else {
res.setHeader('Cache-Control', 'public, max-age=300');
}
next();
});
ความแตกต่างคือ no-cache บังคับ revalidation แต่ไม่บล็อก bfcache ขณะที่ no-store ห้ามทุกการเก็บ cache รวมถึง bfcache ด้วย
2. ลบ unload handler ทั้งหมด
หากใช้ analytics library เก่าที่ยังพึ่ง unload ให้เปลี่ยนเป็น visibilitychange หรือ pagehide:
// ❌ ห้ามใช้ - บล็อก bfcache
window.addEventListener('unload', () => {
navigator.sendBeacon('/analytics', JSON.stringify(data));
});
// ✅ ใช้ pagehide แทน - ทำงานทั้งกรณี unload และ bfcache freeze
window.addEventListener('pagehide', (event) => {
navigator.sendBeacon('/analytics', JSON.stringify(data));
});
// ✅ หรือใช้ visibilitychange สำหรับ analytics ที่ส่งตอน tab hidden
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/analytics', JSON.stringify(data));
}
});
3. ปิด WebSocket connections ก่อน navigation
let socket = null;
function connect() {
socket = new WebSocket('wss://api.example.com/realtime');
}
// ปิด socket เมื่อหน้าถูกซ่อน เพื่อไม่ให้บล็อก bfcache
window.addEventListener('pagehide', () => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close(1000, 'page hidden');
socket = null;
}
});
// เปิดใหม่เมื่อหน้าคืนจาก bfcache
window.addEventListener('pageshow', (event) => {
if (event.persisted && !socket) {
connect();
}
});
ใช้ pageshow และ pagehide แทน unload
การเปลี่ยนจาก lifecycle event เก่า (load, unload) มาใช้ event ใหม่ (pageshow, pagehide) เป็นพื้นฐานสำคัญของการเขียนเว็บที่เข้ากันได้กับ bfcache event ทั้งคู่นี้ถูกออกแบบมาให้ทำงานทั้งใน "fresh load" และ "bfcache restore"
เปรียบเทียบพฤติกรรม:
| เหตุการณ์ | load | pageshow (persisted=false) | pageshow (persisted=true) | unload | pagehide |
| โหลดครั้งแรก | ✅ ยิง | ✅ ยิง | ❌ | ❌ | ❌ |
| นำทางออกแบบปกติ | ❌ | ❌ | ❌ | ✅ ยิง (บล็อก bfcache) | ✅ ยิง |
| กลับมาจาก bfcache | ❌ | ❌ | ✅ ยิง | ❌ | ❌ |
| เข้า bfcache | ไม่มี | ไม่มี | ไม่มี | ❌ | ✅ ยิง |
| บล็อก bfcache? | ไม่ | ไม่ | ไม่ | ✅ ใช่ | ไม่ |
ตัวอย่างการใช้งานเต็มรูปแบบสำหรับ Single Page Application ที่ต้องการ refresh ข้อมูลเมื่อกลับมาจาก bfcache:
// ฟังก์ชันสำหรับ refresh ข้อมูลที่อาจ stale หลังจาก bfcache restore
function handlePageShow(event) {
if (event.persisted) {
// หน้านี้คืนจาก bfcache - ข้อมูลอาจเก่าเกิน 30 นาทีแล้ว
const lastUpdate = parseInt(sessionStorage.getItem('lastUpdate') || '0');
const age = Date.now() - lastUpdate;
if (age > 30 * 60 * 1000) {
refetchUserData();
refetchNotifications();
}
// เปิด real-time connections ใหม่
reconnectWebSocket();
}
}
function handlePageHide(event) {
// ทำงานทั้งกรณีเข้า bfcache (persisted=true) และนำทางออก (persisted=false)
sessionStorage.setItem('lastUpdate', Date.now().toString());
// ส่ง analytics ค้างให้เสร็จ
flushAnalytics();
// ปิด resources ที่บล็อก bfcache
closeWebSocket();
cancelPendingFetches();
}
window.addEventListener('pageshow', handlePageShow);
window.addEventListener('pagehide', handlePageHide);
bfcache กับ Service Worker, WebSocket และ IndexedDB
การมี Service Worker ไม่บล็อก bfcache โดยตรง แต่บางพฤติกรรมใน fetch handler อาจทำให้หน้าหลุด eligibility ได้ จุดที่ต้องระวังคือ:
- Service Worker update: เมื่อ SW เวอร์ชันใหม่ activate ระหว่างที่หน้าอยู่ใน bfcache เบราว์เซอร์จะ evict หน้านั้นออก เพื่อให้แน่ใจว่าผู้ใช้เห็นเวอร์ชันใหม่
- IndexedDB transactions: ต้องปิดทุก transaction ก่อน
pagehide โดยใช้ .commit() หรือปล่อยให้จบเอง ไม่ใช่เก็บ reference ไว้
- WebLock API: lock ที่ยังถืออยู่จะบล็อก bfcache
- Push notification subscriptions: ไม่บล็อก แต่
postMessage จาก SW ไปยังหน้าใน bfcache จะถูกระงับจนกว่าจะ restore
// ตัวอย่างการจัดการ IndexedDB ให้รองรับ bfcache
async function saveData(data) {
const db = await openDB('myapp', 1);
const tx = db.transaction('items', 'readwrite');
await tx.store.add(data);
await tx.done; // สำคัญ! รอให้ commit เสร็จ
// ไม่เก็บ tx ไว้ใน global state
}
window.addEventListener('pagehide', async () => {
// flush ข้อมูลค้างก่อนเข้า bfcache
if (pendingWrites.length > 0) {
await flushPendingWrites();
}
});
ความแตกต่างของ bfcache บน Safari และ Firefox
ทั้งสามเบราว์เซอร์รองรับ bfcache แต่มี criteria และพฤติกรรมต่างกัน
| คุณสมบัติ | Chrome 126+ | Safari 17+ | Firefox 126+ |
| รองรับ HTTPS pages | ✅ | ✅ | ✅ |
| บล็อก unload | ✅ | ✅ | ⚠️ บางส่วน |
| บล็อก beforeunload (desktop) | ✅ | ❌ | ⚠️ บางส่วน |
| บล็อก no-store | ✅ | ✅ | ✅ |
| NotRestoredReasons API | ✅ (เสถียร) | 🔬 (preview) | ❌ |
| Default cache size | 6 หน้า/tab | 3 หน้า | 5 หน้า |
| Memory limit ต่อหน้า | ~60MB | ~50MB | ~40MB |
| Cache TTL | 3 นาที (background tab) / 10 นาที (foreground) | 30 นาที | 30 นาที |
Safari มีอัตราเข้า bfcache สูงสุดในเกือบทุกกรณี เพราะเป็นเบราว์เซอร์แรกที่นำกลไกนี้มาใช้ (ตั้งแต่ปี 2009!) และเข้มข้นกับเงื่อนไขต่าง ๆ น้อยกว่า แต่ก็แลกกับการที่บางหน้าจะถูก cache แม้มี state ที่อาจไม่ถูกต้อง ทำให้ผู้พัฒนาต้อง defensive มากกว่าใน Safari
bfcache ใน Single Page Application (Next.js, Nuxt, Remix)
SPA framework สมัยใหม่ทำให้ bfcache ทำงานได้ดีตามค่าเริ่มต้น แต่มีจุดที่ต้องระวังเฉพาะ framework ที่ทำให้หลายไซต์เสีย eligibility โดยไม่รู้ตัว
Next.js 15 (App Router)
Next.js 15 ส่ง Cache-Control: no-store โดยอัตโนมัติสำหรับ route ที่ใช้ cookies(), headers(), หรือ dynamic API ใน Server Component ซึ่งเป็นต้นเหตุหลักที่ทำให้ bfcache hit rate ของ Next.js apps ต่ำกว่าควรจะเป็น แก้โดยตั้ง revalidate หรือใช้ Partial Prerendering:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export const revalidate = 0;
// แทนที่จะส่ง no-store ให้ override header
export async function generateMetadata() {
return {
other: {
'cache-control': 'private, no-cache, must-revalidate, max-age=0',
},
};
}
Nuxt 3 / Vue Router
Nuxt 3+ มี middleware nuxt-bfcache ที่จัดการ pageshow/pagehide events ให้อัตโนมัติ แต่หาก mount global event listener ใน app.vue ต้อง cleanup ใน onBeforeUnmount ให้ครบ ไม่งั้น memory จะค้างจน Chrome evict หน้าออกจาก bfcache
Remix / React Router 7
Remix ใช้ Cache-Control headers จาก loader function ตรวจสอบว่าทุก loader ที่ return user-specific data ไม่ส่ง no-store ใช้ headers export เพื่อตั้งค่าอย่างเหมาะสม
bfcache vs Speculation Rules vs Prerender
ในปี 2026 มี navigation acceleration technique หลายตัวที่ทำงานต่างกัน หลายทีมสับสนว่าควรใช้ตัวไหน นี่คือสรุปสั้น ๆ
- bfcache: เร่งการนำทาง ย้อนกลับ (Back/Forward) เท่านั้น เปิดใช้งานอัตโนมัติเมื่อหน้าเว็บ eligible ไม่ต้อง opt-in
- Speculation Rules (prerender): เร่งการนำทาง ไปข้างหน้า โดยโหลดและ render หน้าปลายทางล่วงหน้า ต้อง opt-in ผ่าน
<script type="speculationrules">
- HTTP 103 Early Hints: เร่ง initial load โดยให้ browser preload resources ขณะเซิร์ฟเวอร์ยังประมวลผล HTML
ทั้งสามตัวทำงาน ร่วมกัน ได้ดี bfcache ดูแลย้อนกลับ, Speculation Rules ดูแลลิงก์ถัดไป, Early Hints ดูแล cold load อ่านวิธีใช้ Speculation Rules อย่างละเอียดในบทความ Speculation Rules API ปี 2026 และ Early Hints ในบทความ HTTP 103 Early Hints ปี 2026
ผลกระทบต่อ Core Web Vitals
การที่หน้าเข้า bfcache ได้สำเร็จ ส่งผลโดยตรงต่อ Core Web Vitals 3 ตัวหลัก
- LCP: เกือบเป็นศูนย์ เพราะ DOM พร้อมแล้ว ไม่ต้องโหลด image ใหม่
- CLS: คงค่าเดิม (cumulative) แต่ไม่มี layout shift เพิ่ม เพราะ layout เก็บมาแล้ว
- INP: ดีขึ้นมาก เพราะ JavaScript ถูก parse และ initialize ไว้แล้ว ผู้ใช้สามารถ interact ได้ทันที
ตั้งแต่ Chrome 122 ทีม Web Performance ของ Google ตัดสินใจ นับ bfcache navigation ในการคำนวณ CrUX percentiles ซึ่งหมายความว่าไซต์ที่มี bfcache hit rate สูงจะได้ p75 LCP/INP ที่ดีขึ้นในรายงาน web.dev bfcache guide ระบุว่าไซต์ที่ปรับให้ hit rate ≥80% เห็น p75 LCP ลดเฉลี่ย 18% หลังเปลี่ยนแปลง ตัวเลขที่น่าประทับใจสำหรับการเปลี่ยนแปลงระดับ header เพียงไม่กี่บรรทัด
เคล็ดลับเพิ่มเติม หาก measure INP ด้วย web-vitals library ให้ใช้ onINP(callback, { reportAllChanges: true }) เพื่อให้ INP ถูก reset ใหม่หลัง bfcache restoration มิฉะนั้นค่า INP จะรวมการ interact จากครั้งก่อนหน้าด้วย ดูการตั้งค่าและการวิเคราะห์ INP ในเชิงลึกได้ที่บทความ Long Animation Frames API ปี 2026
คำถามที่พบบ่อย
bfcache hit rate ที่ดีควรเป็นเท่าไหร่?
ไซต์ทั่วไปควรมี bfcache hit rate ≥ 50% และไซต์ที่ผู้ใช้นำทางบ่อย (news, e-commerce, blog) ควรอยู่ที่ ≥ 70-80% ค่าเฉลี่ยใน CrUX ปี 2026 คือ 62% หากของคุณต่ำกว่า 30% แสดงว่ามี blocker ใหญ่อย่าง no-store header หรือ unload handler ที่ต้องแก้ก่อน
การใช้ Service Worker จะทำให้ bfcache ทำงานไม่ได้ใช่ไหม?
ไม่ใช่ครับ การมี Service Worker ไม่บล็อก bfcache โดยตรง หน้าที่มี SW สามารถเข้า bfcache ได้ปกติ ยกเว้นกรณีที่ SW activate เวอร์ชันใหม่ระหว่างที่หน้าอยู่ใน cache เบราว์เซอร์จะ evict หน้านั้นเพื่อให้ user เห็นเวอร์ชันใหม่
ทำไม Chrome DevTools บอกว่าหน้า eligible แต่ผู้ใช้จริงยังไม่เข้า bfcache?
DevTools ทดสอบในสภาวะแบบ synthetic ขณะที่ผู้ใช้จริงอาจมีปัจจัยอื่น เช่น memory pressure, tab อยู่ใน background นานเกิน TTL, หรือมี third-party iframe ที่บล็อก ใช้ NotRestoredReasons API ส่งข้อมูลจาก production มาวิเคราะห์เพื่อเห็นภาพจริง
หน้าที่ต้อง login ใช้ bfcache ได้ไหม?
ได้ครับ ตราบใดที่ไม่มี Cache-Control: no-store header การมีข้อมูลผู้ใช้ใน DOM ไม่บล็อก bfcache ใช้ Cache-Control: private, no-cache, max-age=0 แทน เพื่อให้ revalidation ทำงานแต่ยังเข้า bfcache ได้
ควรใช้ beforeunload เพื่อเตือน user เรื่องข้อมูลที่ยังไม่ save อย่างไรโดยไม่บล็อก bfcache?
ลงทะเบียน beforeunload เฉพาะตอนที่มีข้อมูลค้าง และ remove ทันทีเมื่อ save เสร็จ Chrome จะคืน eligibility ให้หน้านั้นเมื่อไม่มี handler อยู่ ตัวอย่าง: form.addEventListener('input', addBeforeUnload); form.addEventListener('submit', removeBeforeUnload);