Chapter 10.5☕ 25 min read

Movie Search App — Fetch, Debounce, Lazy Load & URL Sync

Har keystroke pe API mat maaro, Debounce lagao. Images ko tab load karo jab screen pe aaye, aur URL sync karo taaki link share kar sako.

01🌐 Fetch & Debounce: The Search Core

Search inputs fire input events on every keystroke. If you fetch on every event, you'll hammer the API and get rate-limited or banned.

Debounce the input: wait 300ms of silence before calling the API. If the user types "Inception" (9 keystrokes), you make 1 API call instead of 9.

Show a loading spinner immediately so the user knows something is happening, but only fire the fetch when the user pauses typing.

Handle empty states gracefully — distinguish between "no search yet" (show welcome message) and "no results found" (show empty state with retry).

let searchTimeout;
const searchInput = document.getElementById("search-input");

searchInput.addEventListener("input", (e) => {
  const query = e.target.value.trim();
  clearTimeout(searchTimeout);
  
  if (!query) {
    renderEmptyState();
    return;
  }
  
  showLoadingSpinner();
  
  // Debounce: Wait 300ms after last keystroke
  searchTimeout = setTimeout(async () => {
    try {
      const res = await fetch("/api/movies?q=" + query);
      if (!res.ok) throw new Error("API Error");
      const movies = await res.json();
      renderMovies(movies);
    } catch (err) {
      renderError(err.message);
    }
  }, 300);
});
V8 under the hood: clearTimeout cancels the pending macro-task from the timer queue. Each keystroke resets the 300ms timer. Only when 300ms passes without a new keystroke does the callback execute. This is purely event-loop mechanics — no threading, no Web Workers, just timer queue management.
02👁️ IntersectionObserver: Lazy Loading Images

A search returns 20 movie posters. Downloading all 20 images at once blocks the network and slows page load — especially on mobile with limited bandwidth.

Use data-src instead of src on <img> tags. The browser won't download the image because data-src is a custom attribute — it doesn't trigger resource loading.

IntersectionObserver watches these images. When an image enters the viewport, copy data-src to src — the browser then downloads the image.

Disconnect the observer after the image loads to save memory and prevent redundant checks.

// 1. Render images with data-src (not src!)
function renderMovies(movies) {
  container.innerHTML = "";
  movies.forEach(m => {
    const img = document.createElement("img");
    img.className = "lazy-poster";
    img.dataset.src = m.posterUrl; // Custom attr — no download!
    img.alt = m.title;
    container.appendChild(img);
  });
  initLazyLoad(); // Attach observer
}

// 2. Setup IntersectionObserver
function initLazyLoad() {
  const lazyImages = document.querySelectorAll(".lazy-poster");
  
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src; // NOW trigger download
        img.classList.remove("lazy-poster");
        observer.unobserve(img);   // Stop watching
      }
    });
  }, { rootMargin: "100px" }); // Load 100px before viewport
  
  lazyImages.forEach(img => observer.observe(img));
}
Why rootMargin: "100px"? If you wait until the image enters the viewport to start downloading, the user sees a blank space while loading. rootMargin: "100px" expands the observer's trigger area 100px outside the viewport. The image starts loading just before the user scrolls to it — by the time they see it, it's already loaded. Zero visual delay.
03🔗 URL Search Params: Shareable Links

If a user searches "Inception" and refreshes, the search should persist. Without URL sync, the page reloads to an empty state — frustrating UX.

Sync the search query with the browser URL using URLSearchParams and history.pushState.

On page load, read the URL params and pre-fill the search input and fetch results automatically.

This makes search results shareable via URL — copy the link, send it to a friend, they see the same results.

// 1. Update URL when searching
function updateURL(query) {
  const url = new URL(window.location);
  if (query) {
    url.searchParams.set("q", query);
  } else {
    url.searchParams.delete("q");
  }
  // Update URL without reloading the page
  history.pushState({}, "", url);
}

// 2. Read URL on page load
function loadFromURL() {
  const params = new URLSearchParams(window.location.search);
  const query = params.get("q");
  
  if (query) {
    searchInput.value = query;
    fetchMovies(query); // Pre-fill search
  }
}

// Call on init
window.addEventListener("load", loadFromURL);
pushState vs location.href: Setting window.location.href forces a full page reload — it destroys all JavaScript state. history.pushState changes the URL without reloading. It does NOT fire the popstate event — that only fires when the user clicks Back/Forward buttons. This is the foundation of all SPA routing.
04💖 localStorage Favorites: Persistent State

Users want to save favorite movies. This data must survive page refreshes — localStorage is the answer.

Maintain a favorites array in localStorage. Parse it on load, update it on click, stringify it on save.

Use includes or indexOf to check if a movie is already favorited before adding — prevent duplicates.

Render a heart icon filled/unfilled based on the favorites array to give visual feedback.

function getFavorites() {
  try {
    return JSON.parse(localStorage.getItem("movie_favs")) || [];
  } catch { return []; }
}

function toggleFavorite(movieId) {
  let favs = getFavorites();
  const index = favs.indexOf(movieId);
  
  if (index > -1) {
    favs.splice(index, 1); // Remove
  } else {
    favs.push(movieId);    // Add
  }
  
  localStorage.setItem("movie_favs", JSON.stringify(favs));
  renderMovies(); // Re-render to update heart icons
}

// In render function
function isFav(id) {
  return getFavorites().includes(id);
}
Why try/catch for localStorage? localStorage.getItem() can throw in private browsing mode on some browsers, or when storage is disabled. JSON.parse(null) returns null, which is why we use || [] as a fallback. Always wrap localStorage reads in try/catch for production code.
05🏗️ Bringing It All Together

The Movie App integrates four production patterns: Debounce (Network optimization) + Lazy Loading (Rendering optimization) + URL Params (UX optimization) + localStorage (State persistence).

Always separate data fetching from DOM rendering. Fetch returns data, render consumes it. This makes both testable independently.

Ensure observers and timeouts are cleaned up when new searches start to prevent memory leaks and stale UI.

let currentObserver = null;

async function fetchAndRender(query) {
  updateURL(query);
  showSpinner();
  
  const movies = await fetchMovies(query);
  
  // Cleanup old observer before rendering new
  if (currentObserver) currentObserver.disconnect();
  
  renderMovies(movies); // This sets up new observer
}

// Architecture Flow:
// User Types -> Debounce 300ms -> Fetch API -> Cleanup Old Observer 
// -> Render HTML (data-src) -> Init Lazy Observer -> Images Load
// User Clicks Heart -> Update localStorage -> Re-render
// User Refreshes -> Read URL Params -> Fetch -> Read localStorage -> Render
Always call observer.disconnect() when the component unmounts or the content changes. If you render new movie lists without disconnecting the old observer, you'll have multiple observers running, causing images to load randomly and memory leaks.

Lo kar liya — Key Points:

  • ✅ Debounce search input (300ms delay) to prevent excessive API calls on every keystroke
  • ✅ Use data-src on <img> tags and IntersectionObserver to lazy load images only when they enter the viewport
  • ✅ Sync search queries with the URL using URLSearchParams and history.pushState for shareable and refreshable links
  • ✅ Persist favorite items using a localStorage array, using splice or filter to remove items
  • ✅ Clean up IntersectionObserver instances and setTimeout IDs on re-renders to prevent memory leaks
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase