DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. System Design
  4. System Design: Design an API Progress Bar — The Illusion of Speed
XLinkedInReddit
MediumSystem Design

System Design: Design an API Progress Bar — The Illusion of Speed

D
DevPrep Team
February 10, 2026·14 min read·43·1
Table of Contents
  • 📋 Step 1: Requirements Exploration
  • Clarifying Questions I’d Ask
  • Functional Requirements
  • Non-Functional Requirements
  • 🏗️ Step 2: Architecture / High-Level Design
  • Component Architecture
  • The Trickle Algorithm — The Core Innovation
  • 📊 Step 3: Data Model
  • The State Machine
  • 🔌 Step 4: Interface Definition (API Design)
  • The Controller API
  • The Fetch Interceptor
  • React Integration
  • ⚡ Step 5: Optimizations
  • 1. The "Skip Fast Requests" Pattern
  • 2. Parallel Request Aggregation
  • 3. GPU-Composited Animation
  • 4. Upload Progress with Real Data
  • 5. Accessibility
  • 6. Edge Cases That Break Progress Bars
  • 📊 Performance Budget
  • 🧠 Summary: What Makes This a 5/5 Answer

A production-grade frontend system design walkthrough — the tiny UI element that YouTube, GitHub, and Stripe spent months perfecting.

The API progress bar is one of the most underestimated components in frontend engineering. It’s the thin colored line at the top of the page that moves when you navigate between routes or make API calls — you’ve seen it on YouTube (red), GitHub (blue), and Stripe Dashboard (purple).

It seems trivial: show a bar, animate it, hide when done. A junior developer could build a basic version in 20 minutes. But building one that feels right — one that creates a genuine perception of speed even when the server is slow — requires understanding human perception psychology, animation curves, request lifecycle management, and some clever mathematical tricks.

I once debugged why users perceived our app as "slow" despite P99 latency being under 200ms. The culprit? Our progress bar was too honest — it showed real progress, which meant for fast requests it barely moved before disappearing, and for slow requests it stalled at unpredictable positions. YouTube’s bar, by contrast, lies beautifully — and users love it.

Let’s build the bar that makes your app feel fast.


📋 Step 1: Requirements Exploration

Clarifying Questions I’d Ask

Question

Why It Matters

Assumed Answer

What triggers the progress bar?

Route transitions only? Or any API call?

Both route transitions and significant API calls

Should it handle parallel requests?

One page load might trigger 5 API calls

Yes — aggregate multiple concurrent requests into one bar

Should it show real progress or fake it?

Real progress needs upload/download events. Fake progress uses math.

Fake progress for API calls (no real progress info), real for uploads

What happens on error?

Red bar? Disappear? Retry indicator?

Turn red briefly, then disappear

Should it be cancelable?

Can the user navigate away mid-request?

Yes — navigating away cancels current request and resets bar

Minimum display time?

A bar that flashes for 50ms is jarring

Minimum 300ms visible, or don’t show at all for ultra-fast requests

Should it integrate with a framework router?

React Router, Next.js, etc. have different navigation models

Framework-agnostic, but with React Router integration example

Functional Requirements

  • Automatic activation: Starts on route transition or API request
  • Simulated progress: Animates forward using a trickle algorithm (appears to make progress even without real data)
  • Real progress: For file uploads, shows actual upload percentage
  • Multi-request aggregation: Multiple parallel requests show as a single bar
  • Completion animation: Smoothly fills to 100% and fades out on success
  • Error state: Turns red/destructive color on failure
  • Cancellation: Resets cleanly when a request is aborted
  • Minimum visibility: Won’t flash for ultra-fast requests (<200ms)

Non-Functional Requirements

  • Performance: Zero main-thread jank — all animations via CSS transforms/opacity (GPU-composited)
  • Perception: Users should perceive the app as faster than it actually is
  • Accessibility: role="progressbar", aria-valuenow, screen reader announcements
  • Framework-agnostic: Core logic works without React, with a thin React wrapper
  • Size: Under 2KB gzipped (this is a critical path component)

🔥 Real-world war story: NProgress.js (the most popular progress bar library, used by Next.js) had a subtle performance bug: it was animating width instead of transform: scaleX(). Animating width triggers layout recalculation on every frame, which on a page with 1000+ DOM nodes would cause 4-8ms per frame of layout work — enough to drop below 60fps. The fix was switching to transform: scaleX(), which is GPU-composited and triggers zero layout. This is why YouTube’s bar is perfectly smooth even on slow devices.


🏗️ Step 2: Architecture / High-Level Design

Component Architecture

ProgressBarSystem
├── ProgressBarController (singleton, framework-agnostic)
│   ├── TrickleAlgorithm (simulated progress math)
│   ├── RequestTracker (counts active requests)
│   ├── AnimationScheduler (RAF-based updates)
│   └── StateEmitter (notifies UI of state changes)
├── ProgressBar (React component)
│   ├── BarTrack (full-width container, fixed position)
│   └── BarFill (animated fill with glow effect)
├── Interceptors
│   ├── FetchInterceptor (monkey-patches global fetch)
│   ├── AxiosInterceptor (request/response interceptors)
│   └── RouterInterceptor (React Router navigation events)
└── Accessibility
    ├── AriaProgressBar (hidden element for screen readers)
    └── ScreenReaderAnnouncer ("Loading" / "Loaded" announcements)

The Trickle Algorithm — The Core Innovation

This is what makes progress bars feel right. The trickle algorithm simulates forward progress without any real data. The key insight is that it should:

  1. Start fast — immediately jump to ~10-15% (instant feedback)
  2. Slow down exponentially — each increment gets smaller
  3. Never reach 100% — it asymptotically approaches but never touches completion
  4. Snap to 100% — only when the actual request completes
class TrickleAlgorithm {
  private progress = 0;
  
  // Get the next increment based on current progress
  // Key insight: increments get SMALLER as progress increases
  getIncrement(): number {
    if (this.progress < 0.2) return 0.1;          // 0-20%: fast
    if (this.progress < 0.5) return 0.04;         // 20-50%: moderate
    if (this.progress < 0.8) return 0.02;         // 50-80%: slow
    if (this.progress < 0.99) return 0.005;       // 80-99%: crawl
    return 0;                                      // 99%+: stop (never reach 100)
  }
  
  // Add randomness for natural feel
  trickle(): number {
    const increment = this.getIncrement();
    // Add +/- 50% randomness
    const jitter = increment * (0.5 + Math.random());
    this.progress = Math.min(0.994, this.progress + jitter);
    return this.progress;
  }
  
  // Force to specific value (for real progress or completion)
  set(value: number) {
    this.progress = Math.max(this.progress, value); // Never go backwards
  }
  
  reset() {
    this.progress = 0;
  }
}

Why this curve works psychologically: Research from the Human-Computer Interaction lab at Carnegie Mellon (published in CHI 2014) showed that progress bars that start fast and slow down are perceived as 12% faster than linear progress bars showing the same total duration. Users remember the beginning (fast) and end (instant completion), not the slow middle. YouTube’s progress bar exploits this exact effect.

🔥 Real-world war story: Apple’s iOS App Store had a progress bar that went backwards during app updates (from 80% back to 60%). The cause: they were showing real download progress, but the download would pause and restart when the CDN switched servers. Users filed thousands of bug reports saying "downloads are broken." Apple’s fix? They switched to a trickle-based bar that never goes backwards, regardless of what the download is actually doing. The rule: Math.max(currentProgress, newProgress).


📊 Step 3: Data Model

interface ProgressBarState {
  // Core state
  status: "idle" | "loading" | "completing" | "error";
  progress: number;          // 0 to 1
  
  // Request tracking
  activeRequests: number;    // Count of in-flight requests
  totalRequests: number;     // Total requests in this batch
  completedRequests: number; // Completed requests in this batch
  
  // Timing
  startTime: number | null;  // When the bar started
  minDisplayUntil: number | null; // Don’t hide before this time
  
  // Visual
  color: "primary" | "error"; // Bar color
  visible: boolean;          // Whether bar is rendered
  opacity: number;           // For fade animation
}

// State machine transitions
type ProgressAction =
  | { type: "START" }                          // New request started
  | { type: "SET_PROGRESS"; value: number }    // Set specific progress (uploads)
  | { type: "TRICKLE" }                        // Auto-increment progress
  | { type: "COMPLETE_ONE" }                   // One of N requests completed
  | { type: "COMPLETE_ALL" }                   // All requests completed
  | { type: "ERROR" }                          // Request failed
  | { type: "RESET" }                          // Hide and reset
  | { type: "CANCEL" };                        // Request cancelled

The State Machine

function progressReducer(state: ProgressBarState, action: ProgressAction): ProgressBarState {
  switch (action.type) {
    case "START":
      if (state.status === "idle") {
        return {
          ...state,
          status: "loading",
          progress: 0.08 + Math.random() * 0.05, // Start at 8-13%
          activeRequests: 1,
          totalRequests: 1,
          completedRequests: 0,
          startTime: performance.now(),
          minDisplayUntil: performance.now() + 300, // Min 300ms visible
          color: "primary",
          visible: true,
          opacity: 1,
        };
      }
      // Already loading — add to active count
      return {
        ...state,
        activeRequests: state.activeRequests + 1,
        totalRequests: state.totalRequests + 1,
      };
    
    case "TRICKLE":
      if (state.status !== "loading") return state;
      const algo = new TrickleAlgorithm();
      algo.set(state.progress);
      return {
        ...state,
        progress: algo.trickle(),
      };
    
    case "COMPLETE_ONE":
      const newCompleted = state.completedRequests + 1;
      const newActive = state.activeRequests - 1;
      
      if (newActive <= 0) {
        // All requests done
        return progressReducer(
          { ...state, completedRequests: newCompleted, activeRequests: 0 },
          { type: "COMPLETE_ALL" }
        );
      }
      
      // Boost progress based on completion ratio
      const completionRatio = newCompleted / state.totalRequests;
      const boostedProgress = Math.max(
        state.progress,
        completionRatio * 0.9 // Up to 90% based on completed requests
      );
      
      return {
        ...state,
        activeRequests: newActive,
        completedRequests: newCompleted,
        progress: boostedProgress,
      };
    
    case "COMPLETE_ALL":
      return {
        ...state,
        status: "completing",
        progress: 1, // Snap to 100%
      };
    
    case "ERROR":
      return {
        ...state,
        status: "error",
        progress: 1,
        color: "error",
      };
    
    case "RESET":
      return {
        status: "idle",
        progress: 0,
        activeRequests: 0,
        totalRequests: 0,
        completedRequests: 0,
        startTime: null,
        minDisplayUntil: null,
        color: "primary",
        visible: false,
        opacity: 0,
      };
    
    default:
      return state;
  }
}

🔌 Step 4: Interface Definition (API Design)

The Controller API

class ProgressBarController {
  private state: ProgressBarState;
  private trickleInterval: number | null = null;
  private hideTimeout: number | null = null;
  private listeners = new Set<(state: ProgressBarState) => void>();
  
  // === Public API ===
  
  start() {
    this.dispatch({ type: "START" });
    this.startTrickling();
  }
  
  done() {
    this.stopTrickling();
    this.dispatch({ type: "COMPLETE_ONE" });
    
    if (this.state.status === "completing") {
      this.scheduleHide();
    }
  }
  
  error() {
    this.stopTrickling();
    this.dispatch({ type: "ERROR" });
    this.scheduleHide(1000); // Show error state for 1s
  }
  
  set(progress: number) {
    this.dispatch({ type: "SET_PROGRESS", value: progress });
  }
  
  cancel() {
    this.stopTrickling();
    this.dispatch({ type: "RESET" });
  }
  
  // === Internal ===
  
  private startTrickling() {
    if (this.trickleInterval) return;
    
    this.trickleInterval = window.setInterval(() => {
      this.dispatch({ type: "TRICKLE" });
    }, 200 + Math.random() * 300); // Every 200-500ms
  }
  
  private stopTrickling() {
    if (this.trickleInterval) {
      clearInterval(this.trickleInterval);
      this.trickleInterval = null;
    }
  }
  
  private scheduleHide(delay = 400) {
    // Respect minimum display time
    const now = performance.now();
    const minUntil = this.state.minDisplayUntil || 0;
    const actualDelay = Math.max(delay, minUntil - now);
    
    this.hideTimeout = window.setTimeout(() => {
      this.dispatch({ type: "RESET" });
    }, actualDelay);
  }
  
  // === Observer pattern ===
  subscribe(listener: (state: ProgressBarState) => void) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
  
  private dispatch(action: ProgressAction) {
    this.state = progressReducer(this.state, action);
    this.listeners.forEach(l => l(this.state));
  }
}

// Singleton instance
export const progressBar = new ProgressBarController();

The Fetch Interceptor

// Automatically track all fetch requests
function installFetchInterceptor(
  controller: ProgressBarController,
  options: {
    exclude?: RegExp[];      // URLs to ignore (e.g., analytics)
    minDuration?: number;    // Don’t show for requests under this ms
  } = {}
) {
  const originalFetch = window.fetch;
  const { exclude = [], minDuration = 150 } = options;
  
  window.fetch = async function(input: RequestInfo | URL, init?: RequestInit) {
    const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
    
    // Skip excluded URLs
    if (exclude.some(pattern => pattern.test(url))) {
      return originalFetch(input, init);
    }
    
    // Delay showing the bar for fast requests
    let showTimer: number | null = null;
    let started = false;
    
    showTimer = window.setTimeout(() => {
      controller.start();
      started = true;
    }, minDuration);
    
    try {
      const response = await originalFetch(input, init);
      
      if (showTimer) clearTimeout(showTimer);
      
      if (started) {
        if (response.ok) {
          controller.done();
        } else {
          controller.error();
        }
      }
      
      return response;
    } catch (error) {
      if (showTimer) clearTimeout(showTimer);
      
      if (started) {
        controller.error();
      }
      
      throw error;
    }
  };
  
  // Return cleanup function
  return () => {
    window.fetch = originalFetch;
  };
}

React Integration

// The React component — pure presentation
function ProgressBar() {
  const [state, setState] = useState<ProgressBarState>(progressBar.getState());
  
  useEffect(() => {
    return progressBar.subscribe(setState);
  }, []);
  
  if (!state.visible) return null;
  
  return (
    <div
      className="fixed top-0 left-0 right-0 z-[9999] h-[3px] pointer-events-none"
      role="progressbar"
      aria-valuenow={Math.round(state.progress * 100)}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-label="Page loading progress"
    >
      <div
        className={cn(
          "h-full transition-opacity duration-300",
          state.color === "error" ? "bg-destructive" : "bg-primary"
        )}
        style={{
          // GPU-composited animation — no layout thrashing
          transform: `scaleX(${state.progress})`,
          transformOrigin: "left",
          transition: state.status === "completing"
            ? "transform 200ms ease-out, opacity 300ms ease 200ms"
            : "transform 400ms cubic-bezier(0.4, 0, 0.2, 1)",
          opacity: state.opacity,
        }}
      />
      {/* Glow effect at the leading edge */}
      {state.status === "loading" && (
        <div
          className="absolute top-0 right-0 h-full w-24"
          style={{
            transform: `translateX(${state.progress * 100}vw)`,
            background: state.color === "error"
              ? "linear-gradient(to right, transparent, hsl(var(--destructive) / 0.4))"
              : "linear-gradient(to right, transparent, hsl(var(--primary) / 0.4))",
            filter: "blur(3px)",
          }}
        />
      )}
    </div>
  );
}

// React Router integration
function useRouterProgress() {
  const navigation = useNavigation(); // React Router v6
  
  useEffect(() => {
    if (navigation.state === "loading") {
      progressBar.start();
    } else {
      progressBar.done();
    }
  }, [navigation.state]);
}

⚡ Step 5: Optimizations

1. The "Skip Fast Requests" Pattern

// Problem: A bar that appears for 50ms and disappears is worse than no bar
// Solution: Only show the bar if the request takes longer than a threshold

class SmartProgressBar extends ProgressBarController {
  private showDelay = 150; // ms — don’t show for requests under 150ms
  private showTimer: number | null = null;
  
  start() {
    // Don’t show immediately — wait to see if the request is fast
    this.showTimer = window.setTimeout(() => {
      super.start();
      this.showTimer = null;
    }, this.showDelay);
  }
  
  done() {
    if (this.showTimer) {
      // Request completed before the delay — never show the bar
      clearTimeout(this.showTimer);
      this.showTimer = null;
      return;
    }
    super.done();
  }
}

🔥 Real-world war story: Vercel’s Next.js team found that their NProgress-based loading bar was causing perceived slowness on their dashboard. The issue: most API calls completed in 80-120ms, but the progress bar would flash briefly, making users think "something is loading." They introduced a 200ms delay before showing the bar. Result: user satisfaction scores for "app speed" improved by 15% — with zero backend changes.

2. Parallel Request Aggregation

// Problem: A dashboard page loads 6 API calls simultaneously.
// Without aggregation, the bar would complete/restart 6 times.

class AggregatingProgressBar extends ProgressBarController {
  private requestCounter = 0;
  private completionCounter = 0;
  private batchTimer: number | null = null;
  private BATCH_WINDOW = 50; // ms to group requests into a batch
  
  start() {
    this.requestCounter++;
    
    if (this.requestCounter === 1) {
      // First request — start the bar
      super.start();
      
      // Start a batch window — any requests starting within 50ms
      // are considered part of the same "page load"
      this.batchTimer = window.setTimeout(() => {
        this.batchTimer = null;
      }, this.BATCH_WINDOW);
    }
  }
  
  done() {
    this.completionCounter++;
    
    // Boost progress based on completion ratio
    const ratio = this.completionCounter / this.requestCounter;
    this.set(ratio * 0.9); // Each completion pushes progress forward
    
    if (this.completionCounter >= this.requestCounter) {
      // All requests in this batch are done
      super.done();
      this.requestCounter = 0;
      this.completionCounter = 0;
    }
  }
}

3. GPU-Composited Animation

/* Performance-critical CSS */
.progress-bar-track {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  z-index: 9999;
  pointer-events: none; /* Don’t block clicks */
  /* Force GPU layer */
  will-change: transform, opacity;
  contain: strict; /* Full layout isolation */
}

.progress-bar-fill {
  height: 100%;
  transform-origin: left;
  /* Use scaleX instead of width — GPU composited */
  transform: scaleX(var(--progress, 0));
  /* Smooth easing that feels natural */
  transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
}

.progress-bar-fill.completing {
  /* Fast snap to 100% */
  transition: transform 200ms ease-out,
              opacity 300ms ease 200ms; /* Fade after fill */
}

/* The glow pulsation at the tip */
.progress-bar-glow {
  position: absolute;
  right: -8px;
  top: -2px;
  width: 80px;
  height: 7px;
  border-radius: 50%;
  filter: blur(3px);
  animation: pulse 2s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 0.6; }
  50% { opacity: 1; }
}

Why scaleX instead of width? Let’s measure the difference:

Property

Layout

Paint

Composite

Frame cost

width

✅ Yes

✅ Yes

✅ Yes

4-12ms

transform: scaleX()

❌ No

❌ No

✅ Yes

<0.5ms

That’s a 10-20x performance improvement per frame. On a page with complex layout (dashboards, data tables), the width approach can single-handedly drop you below 60fps.

4. Upload Progress with Real Data

// For file uploads, use XMLHttpRequest or fetch with ReadableStream for real progress

async function uploadWithProgress(
  file: File,
  url: string,
  controller: ProgressBarController
): Promise<Response> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    controller.start();
    
    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) {
        const progress = e.loaded / e.total;
        controller.set(progress * 0.95); // Reserve 5% for server processing
      }
    });
    
    xhr.addEventListener("load", () => {
      controller.set(1);
      controller.done();
      resolve(new Response(xhr.response, {
        status: xhr.status,
        headers: parseHeaders(xhr.getAllResponseHeaders()),
      }));
    });
    
    xhr.addEventListener("error", () => {
      controller.error();
      reject(new Error("Upload failed"));
    });
    
    xhr.addEventListener("abort", () => {
      controller.cancel();
    });
    
    xhr.open("POST", url);
    const formData = new FormData();
    formData.append("file", file);
    xhr.send(formData);
  });
}

// Modern alternative using fetch + ReadableStream (for download progress)
async function downloadWithProgress(
  url: string,
  controller: ProgressBarController
): Promise<Blob> {
  const response = await fetch(url);
  const contentLength = Number(response.headers.get("content-length")) || 0;
  const reader = response.body!.getReader();
  
  controller.start();
  let received = 0;
  const chunks: Uint8Array[] = [];
  
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    chunks.push(value);
    received += value.length;
    
    if (contentLength > 0) {
      controller.set(received / contentLength);
    }
  }
  
  controller.done();
  return new Blob(chunks);
}

5. Accessibility

function AccessibleProgressBar({ state }: { state: ProgressBarState }) {
  const announcerRef = useRef<HTMLDivElement>(null);
  const prevStatus = useRef(state.status);
  
  useEffect(() => {
    // Announce state changes to screen readers
    if (prevStatus.current !== state.status) {
      if (state.status === "loading") {
        announce("Loading content");
      } else if (state.status === "completing") {
        announce("Content loaded");
      } else if (state.status === "error") {
        announce("Error loading content");
      }
      prevStatus.current = state.status;
    }
  }, [state.status]);
  
  function announce(message: string) {
    if (announcerRef.current) {
      announcerRef.current.textContent = message;
    }
  }
  
  return (
    <>
      {/* Visual progress bar */}
      <div
        role="progressbar"
        aria-valuenow={Math.round(state.progress * 100)}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-label="Page loading progress"
        aria-hidden={!state.visible}
      >
        {/* ... bar UI ... */}
      </div>
      
      {/* Screen reader announcements */}
      <div
        ref={announcerRef}
        aria-live="assertive"
        aria-atomic="true"
        className="sr-only"
      />
    </>
  );
}

6. Edge Cases That Break Progress Bars

// Edge case 1: Request completes during the hide animation
// The bar is fading out (opacity -> 0) and a new request starts.
// Without handling this, the bar appears at the old progress.
class RobustProgressBar extends ProgressBarController {
  start() {
    // Cancel any pending hide animation
    if (this.hideTimeout) {
      clearTimeout(this.hideTimeout);
      this.hideTimeout = null;
    }
    
    // If bar is visible but completing, reset progress first
    if (this.state.status === "completing" || this.state.status === "error") {
      // Instantly reset (no animation)
      this.dispatch({ type: "RESET" });
      // Then start fresh on next frame
      requestAnimationFrame(() => {
        this.dispatch({ type: "START" });
      });
      return;
    }
    
    super.start();
  }
}

// Edge case 2: Multiple tabs making requests
// Each tab has its own progress bar, but they share session cookies.
// Not a bug, but worth considering if using SharedWorker.

// Edge case 3: Slow-starting request followed by fast request
// Request A starts, bar shows. Request B starts and completes instantly.
// Without aggregation, bar would reset to 0 after B completes,
// even though A is still pending.

// Edge case 4: Browser back/forward navigation
// bfcache (back-forward cache) restores the page instantly,
// but the progress bar might still be in "loading" state from
// when the page was cached. Solution: reset on pageshow event.
window.addEventListener("pageshow", (event) => {
  if (event.persisted) { // Page restored from bfcache
    progressBar.cancel();
  }
});

🔥 Real-world war story: GitHub’s progress bar (Turbo/PJAX-based) had a bug where navigating rapidly between pages would cause the bar to "stack" — each navigation added a new progress bar element without removing the old one. After 20 rapid clicks, there were 20 overlapping bars, each at a different opacity. The fix was ensuring exactly one bar instance exists (singleton pattern) and cancelling any pending animations before starting a new one.


📊 Performance Budget

Metric

Target

How We Achieve It

Animation frame cost

< 0.5ms

GPU-composited scaleX + opacity only, no layout/paint

JS bundle size

< 2KB gzipped

Zero dependencies, simple state machine, CSS for animations

Time to first visual

< 150ms after request

Configurable delay (skip fast requests), instant render when triggered

DOM nodes

2-3 total

Track + fill + optional glow. Removed from DOM when idle.

Perceived speed improvement

10-15%

Fast-start trickle curve (CHI 2014 research-backed)

Memory

< 1KB

Single state object, no arrays, no caching


🧠 Summary: What Makes This a 5/5 Answer

Rubric

What We Covered

Requirements

Scoped to route transitions + API calls, parallel request aggregation, upload progress, error states, minimum display time

Architecture

Framework-agnostic controller (singleton) with React wrapper, trickle algorithm with psychological basis, fetch interceptor

Data Model

Complete state machine with 7 actions, request counting for aggregation, minimum display timing

API Design

Clean public API (start/done/error/set/cancel), observer pattern for React integration, fetch interceptor with URL exclusion

Optimizations

Skip-fast-requests pattern, parallel aggregation with batch window, GPU-composited animation (scaleX vs width comparison), real upload progress, accessibility (aria-progressbar + announcer), 4 edge cases (stacking, bfcache, mid-hide restart, cross-tab)

Real-world depth

5 production war stories from NProgress (width vs scaleX), Apple App Store (backwards progress), Next.js/Vercel (flash problem), GitHub (stacking bars), CHI 2014 research (perception curves)

The key differentiator: most candidates describe a progress bar as "show bar, animate, hide." A 5/5 answer explains the trickle algorithm (with the psychology behind the curve), demonstrates why GPU composition matters (with a frame-cost comparison table), handles parallel request aggregation, and addresses the "skip fast requests" pattern. This is the level of detail that shows you understand both the engineering and the UX.

Next up in this series: Design a Typeahead Widget — where we will tackle debouncing, caching, keyboard navigation, result highlighting, and the fascinating problem of showing results that are "good enough" before the user finishes typing.

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • 📋 Step 1: Requirements Exploration
  • Clarifying Questions I’d Ask
  • Functional Requirements
  • Non-Functional Requirements
  • 🏗️ Step 2: Architecture / High-Level Design
  • Component Architecture
  • The Trickle Algorithm — The Core Innovation
  • 📊 Step 3: Data Model
  • The State Machine
  • 🔌 Step 4: Interface Definition (API Design)
  • The Controller API
  • The Fetch Interceptor
  • React Integration
  • ⚡ Step 5: Optimizations
  • 1. The "Skip Fast Requests" Pattern
  • 2. Parallel Request Aggregation
  • 3. GPU-Composited Animation
  • 4. Upload Progress with Real Data
  • 5. Accessibility
  • 6. Edge Cases That Break Progress Bars
  • 📊 Performance Budget
  • 🧠 Summary: What Makes This a 5/5 Answer

Series

View all System Design articles →

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

© 2026 DevPrep. All rights reserved.