Back to System Design
System Design
easy
mid

How would you build a priority based data fetching system on the frontend?

Above-the-fold + critical data: high priority, fetched immediately. Below-the-fold: low priority, fetched on idle / when visible. Use `priority` flag on requests, leverage `fetchpriority` attribute for resources, a queue that releases high before low, IntersectionObserver to upgrade priority when a component nears viewport, AbortController to cancel low-priority work when high-priority arrives.

5 min read·~30 min to think through

A priority-based fetcher decides what's important to fetch first so the user sees critical UI fast even when the network is constrained. Useful on mobile and feed-heavy apps.

1. The model

Two or three tiers:

  • Critical / above-the-fold — hero, header data. Fetch immediately.
  • Normal / visible-soon — content the user is about to see.
  • Low / background — analytics, prefetching, below-the-fold extras.

2. Implementation — priority queue + concurrency cap

js
class PriorityFetcher {
  constructor({ concurrency = 6 } = {}) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queues = { high: [], normal: [], low: [] };
  }

  fetch(url, options = {}, priority = "normal") {
    return new Promise((resolve, reject) => {
      const controller = new AbortController();
      const job = { url, options: { ...options, signal: controller.signal }, resolve, reject, controller };
      this.queues[priority].push(job);
      this._dequeue();
      job.cancel = () => controller.abort();
    });
  }

  _dequeue() {
    while (this.running < this.concurrency) {
      const job = this.queues.high.shift() || this.queues.normal.shift() || this.queues.low.shift();
      if (!job) return;
      this.running++;
      window.fetch(job.url, job.options)
        .then((res) => job.resolve(res))
        .catch((err) => job.reject(err))
        .finally(() => { this.running--; this._dequeue(); });
    }
  }

  cancelLow() {
    for (const j of this.queues.low) j.controller.abort();
    this.queues.low = [];
  }
}

3. Browser primitives that help

fetchpriority

For <img> and <link rel="preload">:

html
<img src="hero.jpg" fetchpriority="high">
<link rel="preload" as="image" fetchpriority="high" href="hero.jpg">

The browser bumps these in its internal scheduler.

Priority Hints for fetch

js
fetch(url, { priority: "low" });   // chromium-supported hint

Hints, not guarantees.

requestIdleCallback

Schedule low-priority work in idle time:

js
requestIdleCallback(() => prefetchNextPage(), { timeout: 2000 });

4. Upgrade priority on visibility

A component scrolling toward viewport should bump its data fetch from low to high:

jsx
const ref = useRef();
useEffect(() => {
  const obs = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting) fetcher.upgrade(jobId, "high");
  }, { rootMargin: "200px" });
  obs.observe(ref.current);
  return () => obs.disconnect();
}, []);

5. Cancel low-priority on high

When a critical request comes in and we're saturated, cancel low-priority in-flight work to free a slot:

js
fetcher.cancelLow();
fetcher.fetch(criticalUrl, {}, "high");

AbortController kills the actual request (frees both browser and server).

6. Integration with React Query / SWR

Both have notions of priority via their fetcher functions:

  • Use the priority fetcher as the queryFn.
  • Mark queries as enabled: isVisible for low-priority items.
  • Use prefetchQuery for warm-cache patterns.

7. The actual wins

This pattern matters most:

  • On constrained networks (3G, weak Wi-Fi).
  • For complex pages with many parallel requests.
  • For feed-like UIs where below-the-fold work can starve above-the-fold.

On a fast connection with 5 requests, priority makes little difference. Measure before adding complexity.

8. Patterns to avoid

  • Promise.all on everything — every request fires in parallel; no prioritization possible.
  • Sequential everything — too slow when parallelism would be fine.
  • Custom priority where the browser already prioritizes — for HTTP/2, the browser does some of this; don't fight it.

Interview framing

"Three tiers — critical, normal, low — backed by a priority queue with a concurrency cap. Each fetch goes into its tier's queue; the dispatcher drains high first, then normal, then low. AbortController per job lets us cancel low-priority in-flight work when a high-priority request comes in. IntersectionObserver upgrades a job's priority when its component nears the viewport. Browser primitives help: fetchpriority on resources, { priority: 'low' } on fetch, requestIdleCallback for background work. The pattern earns its keep on constrained networks and feed-heavy UIs — on a fast pipe with few requests, the complexity isn't worth it. Measure first."

Follow-up questions

  • Why use AbortController to cancel low-priority work?
  • What's fetchpriority and when does it actually help?
  • How would this integrate with React Query?
  • When is the complexity NOT worth it?

Common mistakes

  • All requests fired in parallel with no prioritization.
  • Manual priority that fights the browser's scheduler.
  • No cancellation — low priority requests keep running.
  • Premature complexity when the network isn't the bottleneck.

Performance considerations

  • Critical for mobile and feed-heavy apps. Combine with HTTP/2 multiplexing — the browser opens fewer connections than HTTP/1.

Edge cases

  • Low-priority request becomes high after scroll.
  • Network drops with high-priority in flight.
  • Server-side rate limit returns 429 — back off, don't retry rapidly.

Real-world examples

  • News feeds: above-the-fold critical, below-the-fold deferred.
  • Image galleries: visible images first, prefetch on hover.

Senior engineer discussion

Seniors layer priority on top of React Query / SWR, use AbortController for true cancellation, and only deploy the complexity where measurement shows it helps.

Related questions