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.
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);
});
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.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));
}
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.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);
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.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);
}
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.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
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-srcon<img>tags andIntersectionObserverto lazy load images only when they enter the viewport - ✅ Sync search queries with the URL using
URLSearchParamsandhistory.pushStatefor shareable and refreshable links - ✅ Persist favorite items using a
localStoragearray, usingspliceorfilterto remove items - ✅ Clean up
IntersectionObserverinstances andsetTimeoutIDs on re-renders to prevent memory leaks
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login