A search box that fires a request on every keystroke hammers your API and makes results flicker. Debouncing fixes that: wait for the user to pause typing, then act once. Here is a small, reusable debounced input I keep reaching for in Svelte 5.
Why bother? It cuts requests from one-per-keystroke to one-per-pause, which trims server load and bandwidth, kills the flickering of half-typed results, and the component drops into any form with a configurable delay.
The debounce helper
A closure holds a timer. Each call clears the pending timer and schedules a new
one, so the wrapped function only runs after delay milliseconds of silence.
export function debounce(func, delay) { let timeoutId;
return function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => func(...args), delay); };}DebouncedInput.svelte
The component keeps its own value state for the snappy, every-keystroke
binding, and pushes the debounced result out through $bindable(). The
$effect runs update whenever value changes; update only commits to
debouncedValue once typing settles for 300ms.
<script lang="ts"> import { debounce } from '$lib';
let { debouncedValue = $bindable(), initialValue, ...props } = $props();
const update = debounce((v: string) => (debouncedValue = v), 300);
let value = $state(initialValue); $effect(() => update(value));</script>
<input type="text" bind:value {...props} />Usage
Bind your search term to debouncedValue. The parent's $effect only re-runs
when the debounced term changes, so the fetch fires once per pause, not once per
keystroke.
<script lang="ts"> import DebouncedInput from './DebouncedInput.svelte';
let searchTerm = $state(''); let results = $state([]); let loading = $state(false);
$effect(async () => { if (!searchTerm.trim()) { results = []; return; }
loading = true; try { const response = await fetch(`/api/search?q=${encodeURIComponent(searchTerm)}`); results = await response.json(); } catch (error) { console.error('Search failed:', error); results = []; } finally { loading = false; } });</script>
<DebouncedInput bind:debouncedValue={searchTerm} placeholder="Search..." class="w-full px-3 py-2 border rounded"/>
{#if loading} <p>Searching...</p>{:else if results.length > 0} <ul> {#each results as result} <li>{result.title}</li> {/each} </ul>{/if}That is the whole pattern: one closure, one piece of local state, one bindable output. Drop it in front of any search field and your backend stops getting spammed.