Resource Hints and Debouncing

preload, prefetch, dns-prefetch, preconnect. Debouncing user input to API calls; the trade-off between fresh and floody.

Concept Intermediate
11 min read
preload prefetch debounce throttle performance

Summary#

Two adjacent techniques that don’t share a name but share a goal: prepay latency before the user notices, and suppress redundant requests before they hit the wire.

Resource hints are HTML and HTTP signals that tell the browser to start work on a resource it hasn’t been asked to fetch yet. Four hints matter:

  • dns-prefetch — resolve a hostname’s DNS now.
  • preconnect — open the full connection (DNS + TCP + TLS) to a host now.
  • preload — fetch a specific resource now, at the priority you specify.
  • prefetch — fetch a resource now, at idle priority, for the next navigation.

Each shaves a different slice off the connection-setup cost. A page that knows it will call api.example.com shortly can open the connection while the user is still reading the headline; the actual call arrives on a warm socket.

Debouncing and throttling are client-side techniques to suppress fast-firing user input (keystrokes, scroll, resize) so the API isn’t flooded with redundant calls. Debounce: wait for input to settle for N ms before firing once. Throttle: fire at most once every N ms regardless of input rate. Different shapes, different right answers.

Together, the two patterns frame the same question from opposite directions: when does the API call happen? Resource hints pull it earlier; debouncing pushes it later. The senior move is picking deliberately per interaction.

Why it matters#

Three reasons these patterns sit on the API designer’s plate:

  • The TCP/TLS handshake is the dominant per-request latency on a cold connection. A first call to a new origin pays 2-3 RTTs of handshake before the application bytes flow. On a 100ms RTT, that’s 200-300ms of pure setup. preconnect makes it zero by the time the call happens.
  • A typeahead with no debouncing is a self-DoS. Every keystroke fires a search request. Twenty characters typed in three seconds is twenty searches. The API sees 7x the traffic the user actually intended. Rate limits help; debouncing fixes it at source.
  • Both patterns interact with the API design. A preconnect to api.example.com is only useful if the call lands on the right region (edge routing matters). A debounced search call needs the API to handle the case where the user typed more characters before the previous response arrived (cancellation semantics).

The senior signal in an interview: “Search-as-you-type doesn’t fire on every keystroke — it debounces to 300ms — and the page preconnects to the search API on mount so the connection is warm when the call lands.”

How it works#

dns-prefetch — resolve hostname now#

The lightest hint. Tells the browser to do a DNS lookup for a host that will be needed later. Saves the 20-100ms DNS resolution cost when the actual request fires.

<link rel="dns-prefetch" href="https://api.example.com">

Use when: the page will call a host later, but isn’t sure when, and you don’t want to spend a TCP connection on it now. Cheap. Safe to over-include — the browser caps how many it actually performs.

preconnect — open connection now#

Stronger than dns-prefetch. Tells the browser to complete the full connection setup — DNS + TCP + TLS — without sending an HTTP request. Saves 200-300ms on the first call to that origin.

<link rel="preconnect" href="https://api.example.com" crossorigin>

Use when: the page will call this host within seconds. Cost: a TCP socket and a TLS session, idle until used. Don’t over-include — each connection is real OS state.

The crossorigin attribute matters: if the actual fetch will be cross-origin (which most API calls from a frontend are), the preconnect must match or the browser opens a second connection.

preload — fetch resource now#

Tells the browser to download a specific resource right now, at a priority you specify. Different from prefetch (which is idle priority for future navigation): preload is for resources the current page will need shortly.

<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/api/me" as="fetch" crossorigin>
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high">

The as= attribute is mandatory — tells the browser the request type so it sets the right Accept header and priority. The fetchpriority="high" attribute (newer) overrides the default.

Use for the LCP image, critical fonts, and the data the JavaScript will fetch on mount. The browser preloads in parallel with HTML parsing.

103 Early Hints is the server-side cousin: the API server (or CDN) returns a 103 with Link: rel=preload headers before the final response, letting the browser preload while origin still computes.

prefetch — fetch resource for next navigation#

Idle-priority fetch of a resource the next page will need. The browser fetches when the network is otherwise idle; the resource sits in cache; when the user navigates, it’s already there.

<link rel="prefetch" href="/next-page-data.json">
<link rel="prefetch" href="/page/checkout.html">

Use for: the page the user is most likely to navigate to next. Pinterest, Amazon, and most e-commerce sites prefetch the product detail page when the user hovers over a search result. Next.js prefetches all visible links automatically (configurable).

Wrong tool for: critical-path resources (use preload), private user data on a public page (cache leaks).

Hint hierarchy by aggression#

dns-prefetch < preconnect < preload < prefetch < (don't prefetch)
light medium heavy heavy
safe safe risky risky

Light hints (dns-prefetch) are safe to over-include. Heavy hints (preload, prefetch) consume real bandwidth and may waste it if the resource isn’t used.

Debouncing — fire after the storm settles#

A debounced function only fires after the input has been quiet for N ms. Used for typeahead, autosave, resize, scroll-end detection.

function debounce(fn, delay) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
const search = debounce((q) => {
fetch(`/api/search?q=${encodeURIComponent(q)}`);
}, 300);
input.addEventListener("input", (e) => search(e.target.value));

Common delay values:

  • 150-200ms for autosave (feels instant; user perceives “saved”)
  • 300ms for typeahead search (the industry standard; Google, Algolia, ElasticSearch all use it)
  • 500ms for expensive analytics tracking
  • 1000ms+ for very expensive operations (validation against a slow back-end)

300ms is the standard for typeahead because it sits below the conscious-pause threshold (humans pause for ~400ms between thought-bursts) but above the keystroke rate (60-120ms per character for a fast typist).

Throttling — fire at most every N ms#

A throttled function fires at most once per N ms regardless of input frequency. Used for scroll handlers, mousemove, real-time progress updates.

function throttle(fn, interval) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn(...args);
}
};
}
const track = throttle((event) => {
navigator.sendBeacon("/api/scroll-position", JSON.stringify(event));
}, 1000);
window.addEventListener("scroll", track);

Debounce vs throttle, side by side:

Input events: | | || | | ||| | | | | |
0 100 200 300 400 500 600 700 800
Debounced (300ms): ▼
fires once, after input settles for 300ms
Throttled (300ms):
▼ ▼ ▼ ▼
fires at most once per 300ms, regardless

Cancellation — debouncing’s silent partner#

A debounced search fires 300ms after the last keystroke. If the user types more characters during the response window, two responses may come back out of order. The newer query’s response can arrive before the older one’s — the UI shows stale results.

Two fixes:

AbortController — cancel the in-flight request on the next call
let controller;
const search = debounce(async (q) => {
controller?.abort();
controller = new AbortController();
try {
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`,
{ signal: controller.signal });
const data = await r.json();
render(data);
} catch (err) {
if (err.name !== "AbortError") throw err;
}
}, 300);
Sequence number — ignore stale responses
let seq = 0;
const search = debounce(async (q) => {
const mine = ++seq;
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
if (mine !== seq) return; // a newer query has fired
render(await r.json());
}, 300);

AbortController is the modern senior choice — the in-flight request is actually cancelled, freeing the server to skip work on dead queries. Sequence numbers are the fallback when cancellation isn’t possible (e.g. WebSocket message ordering).

What the API can do — server-side companions#

The API designer’s part of the deal:

  • Make the search endpoint cheap. Debouncing limits client-side flooding; the server still has to be fast. Index the search field; cache popular queries.
  • Support cancellation cleanly. Return early if the connection drops mid-query. Don’t keep computing for a client that’s gone.
  • Rate-limit per user, not per IP. A typeahead user on a corporate NAT shouldn’t be throttled by an unrelated user on the same IP. (See rate-limiting.)
  • Return Cache-Control: no-store on search results unless safe. Personalised search results in the browser cache leak across users.

Variants and trade-offs#

Debounce. Wait for input to settle. Fires once, at the end. Right for typeahead, autosave, anything where you only want the final state. Costs latency — the response arrives 300ms after the user stopped typing.

Throttle. Fire at most every N ms. Right for scroll, drag, mousemove — continuous interactions where you want regular sampling. Costs no extra latency but produces N requests per second.

PatternRight forWrong forStandard delay
dns-prefetchHosts likely needed laterCritical-path hosts (use preconnect)n/a
preconnectHosts called within secondsHosts called later in nav (over-spend)n/a
preloadCritical resources on this page (LCP, fonts, hero data)Resources for next page (use prefetch)n/a
prefetchMost likely next navigationCritical-pathn/a
103 Early HintsServer-known critical resourcesStatic-only sites (CDN handles)n/a
DebounceTypeahead, autosave, resize-endScroll, mousemove (use throttle)150-300ms
ThrottleScroll, drag, real-time progressSettled-state events (use debounce)100-1000ms

The senior pairing for a search-as-you-type:

<!-- Mount: open connection to the API host -->
<link rel="preconnect" href="https://api.example.com" crossorigin>
<!-- Optional: preload the empty results endpoint to warm the cache -->
<link rel="preload" href="/api/search?q=&popular=true" as="fetch" crossorigin>
// Debounce keystrokes; cancel in-flight on new query
const search = debounce(async (q) => {
controller?.abort();
controller = new AbortController();
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`,
{ signal: controller.signal });
render(await r.json());
}, 300);

That combination: pre-opened connection, 300ms debounce, cancellation on new query. Three small techniques, two-thirds of the perceived-latency budget gone.

When this is asked in interviews#

Resource hints and debouncing come up in three places:

  • In any search-as-you-type design — typeahead, autocomplete, suggestions. The senior answer combines preconnect on mount, 300ms debounce, AbortController cancellation, server-side query throttling per user.
  • In any “make the first page paint fast” design — preload critical fonts and the LCP image, dns-prefetch third-party hosts, 103 Early Hints from the server. Pair with HTTP/2 or HTTP/3.
  • In any infinite-scroll / live-data design — throttle scroll handlers, debounce resize, batch update calls. Real-time progress updates use throttle, not debounce.

Specific points to make:

  • Name all four resource hints. dns-prefetch, preconnect, preload, prefetch. Explain the priority hierarchy.
  • Name 300ms as the debounce default for typeahead. Justify it (below conscious-pause threshold, above keystroke rate).
  • Distinguish debounce from throttle. Same word in casual use; very different semantics. Debounce fires once at the end; throttle fires at a max rate throughout.
  • Pair debouncing with cancellation. Without AbortController, you have race conditions on out-of-order responses.
  • Connect to the API contract. Cancellation requires the server to handle dropped connections cleanly. Rate limiting backs up client-side debouncing.

The strongest one-liner: “Preconnect on mount, debounce input to 300ms, cancel in-flight on new query. The first call lands on a warm socket; the redundant calls never leave the browser.”

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.