Debounce & Throttle: Tối ưu UI và giảm API call cho Search, Scroll
Hướng dẫn Debounce và Throttle để tối ưu UI: TypeScript implementation, Search Auto-complete, Scroll, React hooks, hủy API cũ và đo hiệu quả thực tế.
ShanverseUpdated 11 min read— viewsLoading views
Performance
UI/UX
Vì sao UI dễ gửi quá nhiều tác vụ?
Browser có thể phát event với tần suất rất cao:
input: mỗi lần người dùng thay đổi nội dung.
scroll: liên tục trong khi cuộn.
pointermove: nhiều lần trong một frame.
resize: dồn dập khi thay đổi kích thước.
Nếu mỗi event đều fetch API, tính layout hoặc update state, ứng dụng có thể gặp:
Request dư thừa và chi phí backend tăng.
Comments
Kết quả search cũ ghi đè kết quả mới.
Main thread bận, input delay và scroll jank.
React render không cần thiết.
API rate limit bị chạm.
Pin thiết bị di động tiêu hao nhanh.
Mermaid
Loading diagram…
Debounce là gì?
Debounce nhóm một burst event thành một lần thực thi.
Ví dụ người dùng gõ notion với delay 300ms:
Trailing edge
Chạy sau khi không có event mới trong wait milliseconds. Đây là mặc định tốt cho:
Search auto-complete.
Validate form sau khi dừng gõ.
Auto-save draft.
Recalculate layout sau resize.
Leading edge
Chạy ngay event đầu tiên rồi khóa các event còn lại trong khoảng chờ. Phù hợp với action cần phản hồi tức thời như chống double-click.
Leading + trailing
Có thể chạy ngay đầu burst và thêm một lần ở cuối nếu có event mới. Cần định nghĩa semantics rõ để không gọi trùng ngoài ý muốn.
maxWait
Nếu event không bao giờ dừng, trailing debounce có thể không chạy. maxWait buộc callback chạy sau một giới hạn tối đa. Production thường dùng implementation đã kiểm thử như Lodash nếu cần đầy đủ leading, trailing, maxWait, cancel và flush.
Tự viết Debounce bằng TypeScript
Implementation dưới đây hỗ trợ trailing callback, cancel() và flush():
setTimeout không đảm bảo chạy đúng tuyệt đối tại mốc wait; callback chỉ chạy khi event loop có thể thực thi.
Search Auto-complete đúng cách
Debounce chỉ trì hoãn request mới. Nó không hủy request cũ đang chạy.
Race condition:
Cần kết hợp:
Debounce input.
AbortController hủy fetch cũ.
Sequence ID hoặc ignore flag để chặn stale result trong các async step không hủy được.
Minimum query length.
Cache/deduplicate query giống nhau.
Aborting client fetch giúp giải phóng công việc phía browser; backend có thể đã nhận và xử lý request. Server vẫn cần rate limiting, timeout, cancellation propagation, cache và capacity protection.
IME và Search quốc tế
Khi gõ bằng bộ gõ tiếng Việt, Trung, Nhật hoặc Hàn, input có thể phát trong quá trình composition. Không nên search chuỗi chưa hoàn chỉnh.
Auto-complete còn cần accessibility: keyboard navigation, focus management, loading state và các ARIA pattern phù hợp cho combobox/listbox.
Throttle là gì?
Throttle giới hạn callback chạy tối đa khoảng một lần trong mỗi interval, nhưng vẫn cập nhật trong suốt quá trình event diễn ra.
Phù hợp với:
Scroll progress.
Analytics vị trí cuộn.
Pointer tracking.
Resize preview.
Đồng bộ trạng thái liên tục nhưng có giới hạn.
Tự viết Throttle bằng TypeScript
Implementation sau chạy leading ngay và giữ trailing call cuối cùng:
Clock system có thể nhảy. Thư viện production thường dùng logic đã test kỹ; performance.now() phù hợp hơn khi cần monotonic elapsed time trong browser.
Scroll: Throttle hay requestAnimationFrame?
Nếu update là visual, đồng bộ với frame bằng requestAnimationFrame thường tự nhiên hơn timer throttle.
requestAnimationFrame không phải universal throttle. Scroll event có thể đã chạy gần frame rate; giá trị chính là gom DOM work và đặt visual update vào frame phù hợp. Callback cũng có thể bị pause/throttle khi tab ở background.
Tránh layout thrashing
Gom DOM reads trước: scrollY, getBoundingClientRect().
Sau đó mới DOM writes: style/class.
Không xen kẽ read/write nhiều lần trong loop.
Tránh handler dài; chia hoặc defer non-critical work.
Passive listeners: hiểu đúng
{ passive: true } nói với browser rằng handler sẽ không gọi preventDefault(). Nó đặc biệt hữu ích với touchstart, touchmove và wheel khi phù hợp.
Passive không làm giảm số lần callback chạy. scroll cơ bản vốn không cancelable, nên cờ passive không thay thế throttle hoặc tối ưu handler.
Khi Observer API tốt hơn Scroll handler
Không cần đo scroll liên tục nếu browser đã có API đúng mục đích:
IntersectionObserver: lazy-load, infinite scroll, element visibility.
ResizeObserver: theo dõi kích thước element.
CSS position: sticky: header bám.
CSS scroll-driven animations khi môi trường hỗ trợ.
Nếu tạo debounced function mới sau mỗi render, timer cũ không được gộp và debounce mất tác dụng. Callback cũng có thể giữ state cũ.
Khi component unmount hoặc wait thay đổi, cleanup phải cancel timer. Network request cũng cần abort/ignore riêng.
React search pattern
Trong ứng dụng lớn, data-fetching layer như framework loader, TanStack Query hoặc SWR có thể xử lý cache, deduplication và lifecycle tốt hơn code thủ công.
Debounce và Throttle khác nhau thế nào?
Chọn delay bao nhiêu?
Không có magic number:
Search local nhẹ: khoảng 100–200ms có thể đủ.
Search qua network: 250–400ms thường là điểm bắt đầu hợp lý.
Expensive validation: có thể 300–500ms.
Scroll analytics: 100–250ms tùy độ chính xác.
Visual scroll: ưu tiên requestAnimationFrame.
Hãy đo theo user behavior, latency và SLO. Delay quá ngắn không giảm nhiều workload; quá dài làm UI có cảm giác chậm.
“Giảm 90% API call” được tính thế nào?
Ví dụ một người gõ 10 ký tự trong 1 giây:
Nhưng nếu người dùng dừng lâu giữa từng ký tự, có thể vẫn tạo nhiều call. Hãy đo:
Ngoài request count, theo dõi:
Keystroke-to-result latency.
Abort/cancel rate.
Cache hit rate.
Stale-result incidents.
API p95/p99 latency và error rate.
Long tasks, INP và dropped frames.
Search conversion/success rate.
Tối ưu ít request nhưng làm người dùng chờ lâu hơn chưa chắc là tối ưu tốt.
Test cases bắt buộc
Gõ liên tục rồi dừng.
Gõ, xóa nhanh và để query rỗng.
Request cũ trả về sau request mới.
Component unmount khi timer/request còn chạy.
Nhấn Enter trước khi debounce chạy: gọi flush() hoặc submit trực tiếp.
IME composition.
Tab chuyển background.
Network chậm, offline, HTTP error và abort.
Scroll bằng mouse, trackpad, touch và keyboard.
Page ngắn không có vùng scroll.
Infinite scroll không gọi trùng page.
Leading/trailing behavior đúng contract.
Fake timers trong unit test.
Anti-patterns
Debounce toàn bộ UI interaction bằng một delay lớn.
Tạo debounced function mới ở mỗi render/event.
Không cancel timer khi unmount.
Tin rằng debounce tự hủy request đang chạy.
Không bảo vệ race condition.
Dùng throttle cho search và gửi hàng loạt query trung gian.
Dùng trailing debounce cho scroll progress nên UI chỉ nhảy khi dừng.
Thực hiện DOM read/write nặng trong scroll handler.
Dùng passive listener nhưng vẫn gọi preventDefault().
Dùng client debounce thay cho server rate limiting.
Khẳng định tỷ lệ giảm API mà không có telemetry.
Production checklist
Xác định cần final value hay continuous updates.
Chọn leading/trailing/maxWait rõ ràng.
Có cancel() và flush() nếu lifecycle cần.
Hủy request cũ và chặn stale result.
Xử lý query rỗng, minimum length và IME.
Giữ callback ổn định trong React.
Cleanup timer, listener, observer và fetch.
Dùng rAF cho visual frame work.
Ưu tiên Observer/CSS thay polling scroll.
Server vẫn có rate limit, cache và timeout.
Đo request reduction cùng UX latency.
Kiểm thử trên mobile và thiết bị yếu.
Kết luận
Chọn Debounce khi chỉ quan tâm giá trị cuối của một burst, điển hình là search auto-complete.
Chọn Throttle khi cần cập nhật trong suốt quá trình nhưng có giới hạn, điển hình là scroll analytics.
Chọn requestAnimationFrame cho visual update đồng bộ frame.
Chọn IntersectionObserver/ResizeObserver khi browser đã có primitive phù hợp.
Debounce và Throttle chỉ hiệu quả khi đi cùng cancellation, race-condition protection, cleanup, observability và backend protection. Đừng tối ưu theo lời hứa “90%”; hãy đo tỷ lệ giảm request và trải nghiệm thực tế.
Hướng dẫn Image Compression và Canvas Resizing trên trình duyệt: thuật toán scale, TypeScript, định dạng WebP/JPEG/PNG, EXIF, CORS, Worker và bảo mật upload.
Hướng dẫn chi tiết cách nâng cấp blog Next.js Notion API với Dark Mode mượt mà, định dạng Markdown đẹp mắt và Syntax Highlighting cho đoạn mã code chỉ trong 15 phút.