แอปของคุณทำงานได้ปกติในช่วงสิบนาทีแรก จากนั้นการเลื่อนหน้าจอ (scroll) ก็เริ่มติดขัด พอผ่านไปครึ่งชั่วโมง แท็บก็ใช้หน่วยความจำพุ่งสูงถึงหนึ่งกิกะไบต์ ในที่สุดหน้าเว็บก็ตายด้วยข้อผิดพลาด out-of-memory โดยที่คุณไม่มี stack trace ใดๆ ให้ตรวจสอบเลย
นี่ไม่ใช่ปัญหาเรื่องประสิทธิภาพการเรนเดอร์ (render performance) React DevTools Profiler จะดูเหมือนไม่มีปัญหาอะไร เพราะประเด็นไม่ได้อยู่ที่ว่าคอมโพเนนต์วาดใหม่บ่อยแค่ไหน แต่อยู่ที่ว่ามีอะไรที่ยังคงค้างอยู่หลังจากที่มัน unmount ไปแล้ว การอ้างอิง (reference) ที่หลงเหลืออยู่ตรงไหนสักแห่งใน JavaScript heap จะยึดโยงทั้งต้นไม้ของ DOM nodes, closures และ state เอาไว้ เบราว์เซอร์จึงไม่สามารถคืนหน่วยความจำส่วนนี้ได้ ทำให้หน่วยความจำพุ่งสูงขึ้นเรื่อยๆ จนกระทั่งกระบวนการทำงานล่มลง
การอ่านซอร์สโค้ดจะไม่ช่วยให้คุณพบจุดที่หน่วยความจำรั่ว (leak) บั๊กนี้ซ่อนอยู่ในช่องว่างระหว่างสิ่งที่คุณคิดว่า unmount ไปแล้ว กับสิ่งที่ garbage collector มองเห็นจริงๆ V8 จะคืนพื้นที่ให้เฉพาะออบเจกต์ที่มี retaining paths เป็นศูนย์เท่านั้น หากมี event listener ที่หลงเหลืออยู่, observer ที่ไม่ได้ถูกล้างออก หรือ closure ที่มีอายุยืนยาว ถือครอง pointer แม้เพียงตัวเดียวไปยัง fiber หรือ DOM node ทั้ง subtree ของคอมโพเนนต์นั้นก็จะยังคงอยู่ คุณสั่ง unmount modal ไปแล้ว แต่ detached nodes ของมันยังคงค้างอยู่ในหน่วยความจำ เพราะ listener บน window ยังคงชี้ไปยัง handler ที่ถูกนิยามไว้ภายใน modal นั้น
หากต้องการพิสูจน์ว่าจุดที่รั่วอยู่ตรงไหน คุณต้องไปดูที่ heap ไม่ใช่ที่ editor
ทำไม Heap ถึงบอกความจริงได้
Chrome DevTools ช่วยให้คุณมองเห็นสิ่งที่ garbage collector เห็นได้โดยตรง แท็บ Memory สามารถบันทึก heap snapshots ซึ่งเป็นการรวบรวมรายการออบเจกต์, DOM node และ closure ทั้งหมดที่ถูกถือครองอยู่ในหน่วยความจำ JavaScript ในขณะนั้น การเปรียบเทียบ snapshot สองชุด—ชุดหนึ่งก่อนที่จะเกิดการรั่วไหลที่สงสัย และอีกชุดหนึ่งหลังจากนั้น—จะช่วยให้คุณแยกแยะได้ว่าออบเจกต์ใดบ้างที่ยังไม่ถูกทำลายทิ้ง
นี่ไม่ใช่ทฤษฎีที่จับต้องไม่ได้ React component ที่รั่วเพียงตัวเดียวสามารถยึดครองออบเจกต์ HTMLElement ที่หลุดจากการเชื่อมต่อ (detached) ได้เป็นพันๆ ตัว ออบเจกต์เหล่านั้นไม่ได้เชื่อมต่อกับ document ที่มองเห็นแล้ว แต่การอ้างอิงใน JavaScript ป้องกันไม่ให้พวกมันถูกเก็บกวาด (collected) พวกมันจะปรากฏในมุมมองการเปรียบเทียบ (comparison view) ด้วยชื่อ constructor ว่า Detached HTMLElement เมื่อคุณเห็นพวกมันเพิ่มจำนวนขึ้นเรื่อยๆ นั่นแปลว่าคุณพบจุดที่หน่วยความจำรั่วแล้ว
ขั้นตอนการทำงานด้วย Chrome DevTools
เริ่มต้นด้วยความสะอาด ปิดแท็บเบราว์เซอร์ที่ไม่เกี่ยวข้อง ปิดการใช้งาน extension ที่ไม่จำเป็น และปล่อยให้แอปพลิเคชันของคุณเข้าสู่สภาวะคงที่ (steady state) เปิด Chrome DevTools สลับไปที่แท็บ Memory และเลือก Heap snapshot จากนั้นคลิก Take snapshot เพื่อบันทึกค่าพื้นฐาน (baseline) ของการใช้หน่วยความจำเริ่มต้นของคุณ
จากนั้นให้ทำกิจกรรมของผู้ใช้ตามที่คุณสงสัย เช่น เปิดและปิด modal ที่หนักๆ นั้น, mount และ unmount widget หรือเปลี่ยน route ไปมา เมื่อ UI กลับคืนสู่สถานะเดิมแล้ว ให้คลิกไอคอนถังขยะในแท็บ Memory เพื่อบังคับให้เกิดการทำ garbage collection ทั่วทั้งระบบ ออบเจกต์ชั่วคราวจากรอบการเรนเดอร์ควรจะหายไป ส่วนอะไรก็ตามที่ยังคงเหลืออยู่คือผู้ต้องสงสัยตัวจริงของการรั่วไหล
คลิก Take snapshot อีกครั้ง ตอนนี้คุณจะมี "ภาพถ่าย" ของหน่วยความจำสองภาพ ให้เปลี่ยนมุมมองจาก Summary เป็น Comparison และตั้งค่าขอบเขตการเปรียบเทียบ (comparison scope) ไปที่ snapshot แรก เครื่องมือจะแสดงเฉพาะสิ่งที่เปลี่ยนแปลงระหว่างการบันทึกทั้งสองครั้ง โดยตัดสัญญาณรบกวน (noise) จาก runtime ออกไป
เรียงลำดับตาม Delta มองหาจำนวนออบเจกต์ที่เพิ่มขึ้น ให้ความสำคัญเป็นพิเศษกับ constructor อย่างเช่น Detached HTMLElement, Array, Function หรือแม้แต่ instance ของ class ที่คุณตั้งชื่อไว้ใน codebase ของคุณเอง ค่า delta ที่เพิ่มขึ้นหมายความว่ามีออบเจกต์ถูกสร้างขึ้นระหว่างการกระทำของคุณและไม่ถูกเก็บกวาดหลังจากนั้น
การแกะรอย Retaining Path
เมื่อคุณพบ element ที่รั่ว ให้เลือกมัน แผงด้านล่างจะแสดง retaining path ซึ่งเป็นสายโซ่ของการอ้างอิงที่อธิบายว่าทำไมออบเจกต์นี้ถึงยังคงอยู่ สายโซ่นี้อาจเริ่มจาก div ที่หลุดการเชื่อมต่อ ไล่ขึ้นไปผ่านคุณสมบัติภายในของ React เข้าสู่ closure และสุดท้ายไปจบที่ event listener ที่ลงทะเบียนไว้ภายในคอมโพเนนต์ของคุณ ลิงก์สุดท้ายในสายโซ่นี้คือเลขบรรทัด (line number) ของคุณ
นี่คือจุดที่คุณจะเปลี่ยนจากการวินิจฉัยไปสู่การหาสาเหตุที่แท้จริง (root cause) หาก retaining path ไปสิ้นสุดที่ window.addEventListener แสดงว่ามี global listener ที่กำลังจับคอมโพเนนต์ของคุณเป็นตัวประกัน หากไปสิ้นสุดที่ instance ของ IntersectionObserver แสดงว่ามี observer ที่ยังคงเฝ้าดู node ที่ควรจะถูก garbage collected ไปแล้ว
ตัวการที่พบบ่อยใน React
การรั่วไหลของหน่วยความจำใน React มักจะแบ่งออกเป็นสามรูปแบบหลักๆ
Orphaned global listeners. เมื่อ useEffect ทำการ hook เข้ากับ window หรือ document เพื่อติดตามตำแหน่งการ scroll, การกดปุ่ม หรือเหตุการณ์ resize หาก effect นั้นไม่ได้คืนค่า (return) cleanup function ที่เรียกใช้ removeEventListener ตัว listener จะยังคงอยู่ตลอดอายุการใช้งานของหน้าเว็บ เนื่องจาก listener เป็น closure มันจึงรักษาขอบเขต (scope) ทั้งหมดของคอมโพเนนต์ให้คงอยู่ต่อไปอีกนานหลังจากที่ React ได้ unmount คอมโพเนนต์นั้นไปแล้ว
Uncleared observers. IntersectionObserver and ResizeObserver are powerful, but they create native references outside React’s control. If you instantiate an observer inside a component and forget to call disconnect() in the cleanup phase, the observer holds the target DOM node, and the DOM node holds React fibers, props, and state.
Closure traps. When you define a function inside a component and pass it to a third-party library, a global cache, or even setTimeout, that function closes over every variable in its lexical scope. If the external owner keeps the function around, it keeps your whole component scope around with it.
Cleanup Patterns That Actually Work
Fixing a leak means severing every retaining path you found in the snapshot.
Always return a cleanup function from useEffect. If you add a listener in the effect, remove it there.
Use useCallback for any handler you attach to the DOM or window. Without it, every render creates a new function reference. If you call addEventListener with one reference and later call removeEventListener with a different one, the removal fails silently. The original listener stays on window forever. useCallback keeps the reference stable so that add and remove match exactly.
Handle observers with the same discipline. Store the observer instance in a ref or local variable inside the effect. In the cleanup function, call observer.disconnect(). Do not assume unmounting the component kills the observer. It does not.
If your component publishes anything to a global namespace or a singleton service, delete those references on unmount. The V8 engine can only reclaim memory when an object is truly unreachable. Leaving a hook on window or an entry in a module-level Map creates an invisible bridge that keeps the heap growing.
The Real Takeaway
Memory leaks do not crash your app immediately. They accumulate one detached node at a time during long user sessions. The fix is not a library upgrade or a compiler flag. It is the habit of proving your cleanup logic with heap snapshots.
Take a baseline, trigger the suspect flow, force garbage collection, and compare. If the delta shows growth, inspect the retaining path, find the listener or observer that should not exist, and cut the reference. Run the test again. When the delta stays flat, you have actually solved it. Your application will stay responsive, and your users will not lose their work to a frozen browser tab.
