DocsHub
JavascriptAdvanced

Debounce Search

Build a search input that waits for the user to stop typing before making an API call using a debounce function.

Debounce Search

Problem

Build a search box that fetches results from an API — but only after the user has stopped typing for 500ms. Without debounce, a network request fires on every single keystroke which is wasteful and slow. With debounce, the request waits until the user pauses.

User types "j"           → waiting... (no request yet)
User types "ja"          → waiting... (timer resets)
User types "jav"         → waiting... (timer resets)
User types "java"        → waiting... (timer resets)
User stops typing...
500ms passes             → API request fires for "java"
Results appear           → shows matching GitHub users

User clears input        → results cleared, no request

Logic

  1. Write a debounce function that wraps another function
  2. The debounce returns a new function that starts a timer on every call
  3. If called again before the timer ends — reset the timer
  4. Only when the timer completes — call the original function
  5. On the search input's input event — call the debounced search function
  6. The search function fetches from GitHub's API
  7. Render results or show a no results message

Flow

yes no yes no User types a character input event fires Debounced function called Clear previous timer Start new 500ms timer User types againbefore 500ms? Timer completes Call fetch function Show loading spinner Fetch from GitHub API Results found? Render user cards Show no results

HTML Structure

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Debounce Search</title>
    <style>
      body {
        font-family: sans-serif;
        max-width: 520px;
        margin: 60px auto;
        padding: 0 20px;
      }

      /* search input wrapper */
      .search-wrapper {
        position: relative;
        margin-bottom: 16px;
      }

      .search-wrapper input {
        width: 100%;
        padding: 14px 44px 14px 16px;
        font-size: 1rem;
        border: 2px solid #ccc;
        border-radius: 8px;
        outline: none;
        box-sizing: border-box;
      }

      .search-wrapper input:focus {
        border-color: #555;
      }

      /* loading spinner inside input */
      #spinner {
        position: absolute;
        right: 14px;
        top: 50%;
        transform: translateY(-50%);
        width: 18px;
        height: 18px;
        border: 2px solid #ddd;
        border-top-color: #555;
        border-radius: 50%;
        animation: spin 0.7s linear infinite;
        display: none;
      }

      @keyframes spin {
        to { transform: translateY(-50%) rotate(360deg); }
      }

      /* status text — shows debounce delay info */
      #status {
        font-size: 0.85rem;
        color: #888;
        margin-bottom: 16px;
        min-height: 20px;
      }

      /* results list */
      #results {
        list-style: none;
        padding: 0;
        margin: 0;
      }

      /* each result card */
      #results li {
        display: flex;
        align-items: center;
        gap: 14px;
        padding: 12px 14px;
        border: 1px solid #e0e0e0;
        border-radius: 10px;
        margin-bottom: 10px;
        text-decoration: none;
        color: inherit;
        transition: background 0.15s;
        cursor: pointer;
      }

      #results li:hover {
        background: #f9f9f9;
      }

      /* avatar image */
      #results li img {
        width: 44px;
        height: 44px;
        border-radius: 50%;
        object-fit: cover;
      }

      .user-info {
        flex: 1;
      }

      .user-info .username {
        font-weight: 600;
        color: #111;
        font-size: 1rem;
      }

      .user-info .profile-link {
        font-size: 0.8rem;
        color: #888;
      }

      /* no results */
      #no-results {
        text-align: center;
        color: #aaa;
        font-size: 0.95rem;
        padding: 24px 0;
        display: none;
      }

      /* error message */
      #error {
        color: #c0392b;
        font-size: 0.9rem;
        display: none;
        padding: 12px;
        background: #fff0f0;
        border-radius: 8px;
        margin-bottom: 12px;
      }
    </style>
  </head>
  <body>
    <h1>GitHub User Search</h1>
    <p style="color:#888; font-size:0.9rem; margin-bottom: 20px;">
      Search waits 500ms after you stop typing before making a request.
    </p>

    <div class="search-wrapper">
      <input
        type="text"
        id="search-input"
        placeholder="Search GitHub users..."
      />
      <!-- loading spinner shown during fetch -->
      <div id="spinner"></div>
    </div>

    <!-- shows typing / fetching status -->
    <div id="status"></div>

    <!-- error message -->
    <div id="error"></div>

    <!-- results rendered here -->
    <ul id="results"></ul>

    <!-- no results message -->
    <div id="no-results">No users found.</div>

    <script src="script.js"></script>
  </body>
</html>

Solution

// Step 1 — select elements
const searchInput = document.querySelector("#search-input");
const spinner = document.querySelector("#spinner");
const status = document.querySelector("#status");
const results = document.querySelector("#results");
const noResults = document.querySelector("#no-results");
const errorBox = document.querySelector("#error");

// Step 2 — the debounce function
// takes a function and a delay in milliseconds
// returns a new function that delays calling the original
function debounce(fn, delay) {
  let timer; // timer lives in the closure — persists between calls

  return function (...args) {
    // clear the previous timer on every call
    // this is what resets the countdown each time the user types
    clearTimeout(timer);

    // show "typing..." status so user sees the debounce in action
    status.textContent = "Typing...";

    // start a new timer
    // only fires if this function is not called again within delay ms
    timer = setTimeout(() => {
      fn.apply(this, args); // call the original function with the arguments
    }, delay);
  };
}

// Step 3 — the actual search function
// this is what gets called after the debounce delay
async function searchUsers(query) {
  query = query.trim();

  // clear results and error if input is empty
  if (!query) {
    clearResults();
    status.textContent = "";
    return;
  }

  // Step 4 — show loading state
  showLoading();
  status.textContent = `Searching for "${query}"...`;

  try {
    // Step 5 — fetch from GitHub search API
    // the API searches for users whose username matches the query
    const response = await fetch(
      `https://api.github.com/search/users?q=${encodeURIComponent(query)}&per_page=6`
    );

    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status}`);
    }

    const data = await response.json();

    // Step 6 — render results
    hideLoading();
    renderResults(data.items, query);

  } catch (err) {
    hideLoading();
    showError(`Failed to fetch results: ${err.message}`);
  }
}

// Step 7 — create the debounced version of searchUsers
// 500ms delay — only fires when user stops typing for 500ms
const debouncedSearch = debounce(searchUsers, 500);

// Step 8 — listen for input event and call debounced search
searchInput.addEventListener("input", (e) => {
  debouncedSearch(e.target.value);
});

// Step 9 — render the list of users
function renderResults(users, query) {
  results.innerHTML = "";
  errorBox.style.display = "none";

  if (users.length === 0) {
    noResults.style.display = "block";
    status.textContent = `No results for "${query}"`;
    return;
  }

  noResults.style.display = "none";
  status.textContent = `Found ${users.length} result${users.length !== 1 ? "s" : ""} for "${query}"`;

  users.forEach((user) => {
    const li = document.createElement("li");

    // clicking the result opens the user's GitHub profile
    li.addEventListener("click", () => {
      window.open(user.html_url, "_blank");
    });

    li.innerHTML = `
      <img src="${user.avatar_url}" alt="${user.login}'s avatar" />
      <div class="user-info">
        <div class="username">${user.login}</div>
        <div class="profile-link">github.com/${user.login}</div>
      </div>
    `;

    results.appendChild(li);
  });
}

// helpers
function showLoading() {
  spinner.style.display = "block";
  results.innerHTML = "";
  noResults.style.display = "none";
  errorBox.style.display = "none";
}

function hideLoading() {
  spinner.style.display = "none";
}

function clearResults() {
  results.innerHTML = "";
  noResults.style.display = "none";
  errorBox.style.display = "none";
}

function showError(message) {
  errorBox.textContent = message;
  errorBox.style.display = "block";
  status.textContent = "";
}

Code Execution

Trace through the debounce when user types "java" quickly:

KeystrokeActionTimer
"j"clearTimeout(undefined)setTimeout(search, 500)timer 1 starts
"ja"clearTimeout(timer1)setTimeout(search, 500)timer 1 cancelled, timer 2 starts
"jav"clearTimeout(timer2)setTimeout(search, 500)timer 2 cancelled, timer 3 starts
"java"clearTimeout(timer3)setTimeout(search, 500)timer 3 cancelled, timer 4 starts
500ms passnothing typedtimer 4 fires → searchUsers("java")

Trace through API call for "java":

StepCodeResult
Encode queryencodeURIComponent("java")"java"
Fetch URLhttps://api.github.com/search/users?q=java&per_page=6API call made
Check okresponse.oktrue
Parse JSONresponse.json(){ items: [...6 users] }
Render6 user cards shownresults visible

Output

Type "j"          → Typing... (no request)
Type "ja"         → Typing... (timer resets)
Type "java"       → Typing... (timer resets)
Stop typing...    → Searching for "java"...
500ms later       → Found 6 results for "java"
Clear input       → results cleared, no request made

On this page