Debounce: gộp một loạt sự kiện thành một lần xử lý cuối
Debounce trì hoãn việc thực thi cho tới khi một sự kiện ngừng phát ra trong một khoảng thời gian. Mỗi lần sự kiện mới đến, đồng hồ đếm ngược được reset; chỉ khi im lặng đủ lâu, hàm mới chạy — với lần gọi cuối cùng. Đây là công cụ chuẩn cho ô tìm kiếm gõ liên tục (chỉ gọi API khi người dùng dừng gõ), cho việc validate form, cho auto-save. Làm sai (delay quá dài, không hủy, hoặc gọi API đã lỗi thời) biến một tối ưu thành trải nghiệm tệ và request lãng phí.
Cơ chế hoạt động
Debounce dựa trên closure giữ một timer; mỗi lần gọi clear timer cũ và đặt timer mới:
function debounce(fn, delay) {
let timer
function debounced(...args) {
clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay) // chỉ chạy nếu không bị reset
}
debounced.cancel = () => clearTimeout(timer) // hủy khi unmount
return debounced
}
Mỗi lần gọi debounced hủy lịch trước và đặt lại — nên chuỗi gọi dồn dập chỉ dẫn tới một lần chạy fn, sau khi ngừng delay ms. Giữ this/args của lần gọi cuối để fn chạy với dữ liệu mới nhất. Hàm cancel để gỡ timer còn treo khi component unmount — bỏ sót cái này là một nguồn lỗi.
Vấn đề gặp trong production
Use case điển hình: search-as-you-type. Không debounce thì mỗi phím gõ là một request:
input.addEventListener('input', debounce((e) => {
searchApi(e.target.value) // chỉ gọi khi người dùng ngừng gõ ~300ms
}, 300))
Người dùng gõ "iphone" sẽ bắn 6 request không debounce; với debounce chỉ 1 request sau khi dừng gõ. Giảm tải server, giảm chi phí, tránh kết quả nhấp nháy.
Failure mode: race condition giữa các response. Debounce giảm số request nhưng không đảm bảo thứ tự response. Nếu delay ngắn và mạng chậm, một request cũ có thể về sau request mới, ghi đè kết quả đúng bằng kết quả lỗi thời:
// request "ipho" về sau request "iphone" -> hiển thị kết quả của "ipho"
Phải hủy/bỏ qua response cũ — bằng AbortController để hủy request trước, hoặc kiểm tra response còn ứng với input hiện tại không trước khi render. Debounce một mình không giải quyết được race này.
Failure mode: delay sai. Delay quá dài (700ms+) làm giao diện cảm giác lag, người dùng tưởng app đơ; quá ngắn (50ms) thì gần như không debounce. Khoảng 250–400ms cho search là vùng thường hợp lý, nhưng phải đo theo hành vi thật.
Failure mode: timer không hủy khi unmount. Component gỡ đi nhưng timer còn treo sẽ chạy fn trên state/DOM đã chết — gọi cancel() trong cleanup.
Cách debug và monitor
Nếu thấy số request tới API tìm kiếm cao bất thường so với số lần người dùng thực sự tìm, kiểm tra debounce có được áp đúng không (dễ sai: tạo hàm debounced mới mỗi lần render nên timer không bao giờ được giữ qua các lần gọi — phải tạo một lần và giữ ổn định). Với kết quả "thỉnh thoảng hiển thị sai/cũ", nghi race condition response và kiểm tra có hủy request cũ không. Theo dõi tỷ lệ request bị hủy (AbortController) — tỷ lệ hợp lý xác nhận cơ chế hủy hoạt động. Trong React, dùng useMemo/useRef để giữ hàm debounced ổn định qua render, lỗi tạo lại mỗi render là bug hay gặp.
Tradeoff
Debounce giảm mạnh số lần xử lý cho sự kiện bùng nổ (gõ, resize), tiết kiệm tải và chi phí — đổi lại thêm độ trễ (người dùng phải ngừng delay ms mới thấy kết quả) và mất các lần gọi trung gian (chỉ giữ lần cuối). So với throttle (chạy đều đặn theo nhịp), debounce hợp khi chỉ cần kết quả cuối sau khi ngừng (search, auto-save); throttle hợp khi cần cập nhật đều trong lúc sự kiện đang diễn ra (scroll, kéo thả). Quy tắc thực tế: search/validate/auto-save → debounce; cập nhật theo nhịp khi đang tương tác → throttle; và luôn xử lý race response + hủy timer khi unmount.
Câu hỏi phỏng vấn
Debounce khác throttle thế nào, và debounce một mình có đủ cho search-as-you-type không?
Debounce trì hoãn thực thi tới khi sự kiện ngừng phát trong delay ms — mỗi sự kiện mới reset đồng hồ, nên một chuỗi dồn dập chỉ dẫn tới một lần chạy với dữ liệu cuối; throttle thì cho hàm chạy đều đặn tối đa một lần mỗi khoảng, phù hợp cập nhật liên tục như scroll. Cho search-as-you-type, debounce giảm số request (chỉ gọi khi người dùng dừng gõ) nhưng không đủ: nó không đảm bảo thứ tự response, nên một request cũ về muộn có thể ghi đè kết quả mới — phải kèm hủy request cũ bằng AbortController hoặc bỏ qua response không khớp input hiện tại. Điểm ăn điểm: chọn delay theo hành vi thật (~300ms), giữ hàm debounced ổn định qua render (không tạo lại mỗi lần), và cancel timer khi unmount để không chạy trên state đã chết.
Hands-on
Dựng một ô tìm kiếm thật gọi API, đầu tiên không debounce để đếm số request khi gõ một từ; thêm debounce(300) và xác nhận chỉ còn một request sau khi ngừng gõ. Mô phỏng mạng chậm không đều để tái hiện race condition (kết quả cũ ghi đè mới), rồi sửa bằng AbortController hủy request trước mỗi lần gọi mới. Trong một component React, cố tình tạo hàm debounced ngay trong thân render để thấy debounce không hoạt động (timer bị tạo lại mỗi render), rồi sửa bằng useMemo/useRef và thêm cancel trong cleanup của useEffect.
Top comments (0)