Debouncing và Throttling trong JavaScript: Tối Ưu Xử Lý Sự Kiện và Giảm Tải API

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bối Cảnh & Lý Do: Event Handler Của Bạn Đang Có Thể Làm Chậm Ứng Dụng

Hãy tưởng tượng: bạn xây dựng một ô tìm kiếm. Người dùng gõ “docker compose” — 14 lần nhấn phím — và frontend của bạn bắn ra 14 request API riêng biệt trong khoảng hai giây. Backend cố gắng theo kịp, rate limit bắt đầu kích hoạt, và UI bắt đầu cảm thấy chậm chạp. Nghe quen không?

Debouncing và throttling giải quyết chính xác vấn đề này — và chúng là hai kỹ thuật tối ưu hiệu năng thực tế nhất trong bộ công cụ JavaScript của bạn.

Đây là sự khác biệt cốt lõi:

  • Debounce — chờ cho đến khi người dùng dừng thao tác rồi mới kích hoạt. Gõ 14 ký tự, chỉ có 1 lần gọi API (sau một khoảng dừng ngắn).
  • Throttle — giới hạn tần suất kích hoạt hàm bất kể có bao nhiêu sự kiện đến. Cuộn 500 lần mỗi giây, handler của bạn chỉ chạy tối đa một lần mỗi 200ms.

Tôi đã dùng cả hai trong môi trường production — search autocomplete, infinite scroll, tính toán lại khi resize window, tự động lưu form. Hiệu quả có thể đo lường được: số lần gọi API giảm 80–90% chỉ riêng với ô tìm kiếm. Trải nghiệm người dùng thực sự cải thiện vì UI không còn bị giật nữa.

Đây là khi nào nên dùng từng loại:

  • Dùng debounce cho: ô tìm kiếm, validate form khi keyup, tự động lưu nháp, tính toán resize chỉ quan trọng ở kích thước cuối cùng
  • Dùng throttle cho: scroll event listener, theo dõi vị trí chuột, game loop, polling dữ liệu real-time

Cài Đặt: Hai Cách Để Có Debounce và Throttle

Có hai lựa chọn: dùng thư viện đã được kiểm chứng hoặc tự viết một implementation tối giản. Cả hai đều được — tùy thuộc vào nhu cầu của dự án.

Lựa Chọn 1: Dùng Lodash (Khuyến Nghị Cho Hầu Hết Dự Án)

Lodash đã tồn tại từ năm 2012, được dùng trong hàng triệu dự án, và hỗ trợ tree-shaking trong các bundler hiện đại — nên bạn chỉ bundle những gì thực sự import. Với Node.js hoặc dự án frontend dùng bundler:

# npm
npm install lodash

# yarn
yarn add lodash

# pnpm
pnpm add lodash

Chỉ cần debounce và throttle mà không muốn pull in toàn bộ Lodash bundle?

# Chỉ cài các hàm cụ thể (bundle nhỏ hơn nhiều)
npm install lodash.debounce lodash.throttle

Dùng thử trên trình duyệt? Load từ CDN trong một dòng:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"></script>

Lựa Chọn 2: Tự Viết (Không Phụ Thuộc Thư Viện Nào)

Không phụ thuộc thư viện nào là lựa chọn hoàn toàn hợp lệ — đặc biệt khi bạn đang học cách nó hoạt động bên trong. Đặt đoạn code này vào file utils.js:

// utils.js

export function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

export function throttle(fn, limit) {
  let lastCall = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      return fn.apply(this, args);
    }
  };
}

Cố ý giữ tối giản. Các phiên bản của Lodash xử lý các trường hợp đặc biệt như hủy, flush, và các tùy chọn leading/trailing — nhưng với 90% trường hợp sử dụng, đây là tất cả những gì bạn cần.

Cấu Hình: Áp Dụng Vào Các Tình Huống Thực Tế

Tình Huống 1: Ô Tìm Kiếm Với Debounce

Ô tìm kiếm là tình huống điển hình nhất cho debounce. Người dùng gõ và bạn chỉ muốn đúng một lần gọi API — kích hoạt sau khi họ dừng gõ, không phải mỗi lần nhấn phím.

// Dùng Lodash
import debounce from 'lodash/debounce';

const searchInput = document.getElementById('search');

const fetchResults = async (query) => {
  if (!query.trim()) return;
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const data = await res.json();
  renderResults(data);
};

// Chờ 400ms sau khi người dùng ngừng gõ rồi mới kích hoạt
const debouncedSearch = debounce(fetchResults, 400);

searchInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

400ms là giá trị mặc định tốt. Dưới 200ms, bạn vẫn đang bắn quá nhiều lần gọi. Trên 600ms, người dùng bắt đầu cảm nhận được độ trễ. Điều chỉnh dựa trên tốc độ gõ của người dùng và điều kiện mạng.

Tình Huống 2: Scroll Handler Với Throttle

Sự kiện scroll có thể kích hoạt hơn 60 lần mỗi giây — nhiều hơn trên màn hình có tần số quét cao. Kiểm tra vị trí scroll thường xuyên như vậy rất lãng phí. Throttle cho phép bạn xử lý với tốc độ kiểm soát được, như một lần mỗi 200ms, mà không bỏ lỡ các cập nhật vị trí thực sự quan trọng.

import throttle from 'lodash/throttle';

const loadMoreContent = () => {
  const scrollPosition = window.scrollY + window.innerHeight;
  const documentHeight = document.documentElement.scrollHeight;

  // Tải thêm khi người dùng còn 200px từ cuối trang
  if (scrollPosition >= documentHeight - 200) {
    fetchNextPage();
  }
};

// Kích hoạt tối đa một lần mỗi 200ms
const throttledScrollHandler = throttle(loadMoreContent, 200);

window.addEventListener('scroll', throttledScrollHandler);

// Dọn dẹp khi component unmount (ví dụ React)
return () => {
  window.removeEventListener('scroll', throttledScrollHandler);
  throttledScrollHandler.cancel(); // Phương thức Lodash để flush các lời gọi đang chờ
};

Tình Huống 3: Tích Hợp React Hooks

Vòng render của React tạo ra một cạm bẫy tinh vi ở đây. Một hàm debounced mới trong mỗi lần render sẽ reset timer nội bộ — nghĩa là debounce sẽ không bao giờ thực sự kích hoạt. Hãy khóa nó lại bằng useCallback hoặc useRef để giữ nguyên tham chiếu hàm qua các lần render.

import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';

function SearchBox() {
  const [results, setResults] = useState([]);

  // useCallback đảm bảo hàm debounced chỉ được tạo một lần
  const debouncedSearch = useCallback(
    debounce(async (query) => {
      if (!query) return setResults([]);
      const res = await fetch(`/api/search?q=${query}`);
      const data = await res.json();
      setResults(data);
    }, 400),
    [] // Deps rỗng: chỉ tạo một lần khi mount
  );

  return (
    <input
      type="text"
      placeholder="Tìm kiếm..."
      onChange={(e) => debouncedSearch(e.target.value)}
    />
  );
}

Tình Huống 4: Bảo Vệ Khỏi Rate Limiting API

Các API bên thứ ba có rate limit cần một giới hạn cứng về tần suất gọi. Throttle là công cụ phù hợp. Debounce sẽ bỏ qua quá nhiều lần gọi trung gian — throttle đảm bảo throughput ổn định và có thể dự đoán được.

import { throttle } from 'lodash';

const geocodeAddress = async (address) => {
  const res = await fetch(
    `https://api.example.com/geocode?address=${encodeURIComponent(address)}&key=${API_KEY}`
  );
  return res.json();
};

// API bên thứ ba cho phép tối đa 10 request mỗi giây
// Throttle xuống 1 request mỗi 150ms = ~6,5 req/giây (buffer an toàn)
const throttledGeocode = throttle(geocodeAddress, 150);

// Giờ bạn có thể gọi hàm này mỗi khi input thay đổi mà không lo ngại
document.getElementById('address').addEventListener('input', (e) => {
  throttledGeocode(e.target.value).then(displayOnMap);
});

Kiểm Tra & Giám Sát: Xác Nhận Nó Thực Sự Hoạt Động

Kiểm Tra Nhanh: Thời Gian Console

Bắt đầu đơn giản. Một console.log với timestamp cho bạn biết ngay liệu debounce có đang kích hoạt đúng lúc hay không — không cần DevTools.

// Bọc lời gọi fetch để ghi log khi nó thực sự kích hoạt
const debouncedSearch = debounce((query) => {
  console.log(`[${new Date().toISOString()}] Lời gọi API kích hoạt: "${query}"`);
  fetchResults(query);
}, 400);

Gõ nhanh vào ô tìm kiếm. Chỉ một dòng log sẽ xuất hiện sau khi bạn dừng — không phải một dòng mỗi ký tự. Nếu bạn thấy hàng loạt log xuất hiện, debounce chưa được kết nối đúng.

Browser DevTools: Tab Network

Mở Chrome DevTools → tab Network → lọc theo API endpoint của bạn. Gõ từ “javascript” (10 ký tự) vào ô tìm kiếm đã debounce. Chỉ một network request sẽ xuất hiện, không phải 10. Nếu bạn thấy 10 request, debounce của bạn chưa được kết nối đúng.

Với throttle: cuộn nhanh trong 5 giây và đếm số request. Với throttle 200ms, kỳ vọng tối đa 25 lần (5.000ms ÷ 200ms). Nếu bạn thấy hàng trăm lần, throttle chưa được kết nối.

Giám Sát Hiệu Năng Trong Production

Sau khi deploy, thêm tracking nhẹ để xem con số thực tế. Hoạt động với Datadog, New Relic, hoặc bất kỳ hệ thống logging backend nào bạn đang có:

// Tracking cơ bản: đếm sự kiện thô so với lời gọi API thực tế
let rawEventCount = 0;
let debouncedCallCount = 0;

searchInput.addEventListener('input', () => rawEventCount++);

const trackedSearch = debounce((query) => {
  debouncedCallCount++;

  // Ghi tỷ lệ vào dịch vụ monitoring của bạn
  if (window.analytics) {
    window.analytics.track('search_efficiency', {
      raw_events: rawEventCount,
      actual_calls: debouncedCallCount,
      reduction_ratio: (1 - debouncedCallCount / rawEventCount).toFixed(2)
    });
  }

  fetchResults(query);
}, 400);

Khi tôi thêm tracking này vào tính năng tìm kiếm production, tỷ lệ duy trì ở mức 85–90% ít lần gọi API hơn trên toàn bộ hệ thống. Độ trễ backend giảm đáng kể. Chúng tôi vẫn ở dưới giới hạn rate limit của nhà cung cấp API ngay cả trong các đợt traffic tăng đột biến.

Các Lỗi Thường Gặp Cần Tránh

  • Tạo hàm debounced mới trong mỗi lần render — bọc nó trong useCallback hoặc định nghĩa bên ngoài component
  • Quên hủy khi unmount — gọi debouncedFn.cancel() trong cleanup để tránh cập nhật state trên các component đã unmount
  • Dùng debounce khi cần throttle — nếu bạn đang xử lý dữ liệu real-time (vị trí chuột, vật lý cuộn trang), debounce có thể bỏ qua quá nhiều sự kiện; throttle mới là công cụ phù hợp
  • Đặt delay quá cao — debounce 800ms+ trên ô tìm kiếm làm ứng dụng cảm thấy không phản hồi. 300–500ms phù hợp với hầu hết các giao diện tìm kiếm

Debouncing và throttling là những bổ sung nhỏ nhưng mang lại hiệu quả không tương xứng. Mười dòng code trong ô tìm kiếm có thể cắt giảm API traffic 80%, giảm chi phí backend, và làm UI cảm thấy nhanh nhạy hơn — cùng một lúc. Hãy chọn bất kỳ input nào bạn đang xây dựng ngay bây giờ và áp dụng thôi. Sự khác biệt sẽ hiện ra nhanh chóng.

Share: