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.

lib/debounce.js
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.

DebouncedInput.svelte
<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.

SearchBox.svelte
<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.