DEV Community

dayo adewuyi
dayo adewuyi

Posted on

The Art of Loading States: Reimagining Loading Experiences at Scale

A deep dive into how I transformed our product's loading patterns from basic spinners into meaningful, engaging moments of anticipation.

The Spark:

I've always been bothered by loading states. Most implementations, including our own, defaulted to the ubiquitous spinning circle – a universal symbol of "wait and do nothing." During a late-night coding session, watching yet another spinner, I realized: we were wasting precious moments of user attention and breaking the illusion of seamless interaction.

The Challenge Landscape:

Our application, a collaborative design system platform used by 10+ internal teams, had grown to handle complex data relationships. Each page load typically involved:

Nested data dependencies
State synchronization across WebSocket connections
Complex component trees with varying loading states

The real complexity wasn't just handling these loads – it was maintaining visual coherence and state predictability across hundreds of possible loading combinations.

The Technical Deep-Dive:

I developed what I call "Predictive Loading Orchestration" (PLO). Here's a simplified version of the core implementation:

interface LoadingConfig {
  id: string;
  priority: number;
  estimatedDuration?: number;
  dependencies?: string[];
  placeholder?: React.ComponentType;
  fallback?: React.ComponentType;
  onLoadStart?: () => void;
  onLoadComplete?: () => void;
}

type LoadingState = {
  status: 'idle' | 'loading' | 'loaded' | 'error';
  startTime?: number;
  duration?: number;
  progress: number;
  dependencies: Set<string>;
}

class LoadingOrchestrator {
  private loadingStates: Map<string, LoadingState> = new Map();
  private estimatedDurations: Map<string, number[]> = new Map();
  private subscribers: Set<(states: Map<string, LoadingState>) => void> = new Set();

  constructor() {
    this.setupPerformanceObserver();
  }

  private setupPerformanceObserver() {
    if (typeof PerformanceObserver !== 'undefined') {
      const observer = new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          this.updateEstimatedDuration(entry.name, entry.duration);
        });
      });

      observer.observe({ entryTypes: ['resource', 'measure'] });
    }
  }

  private updateEstimatedDuration(id: string, duration: number) {
    const durations = this.estimatedDurations.get(id) || [];
    durations.push(duration);


    if (durations.length > 10) durations.shift();

    this.estimatedDurations.set(id, durations);
  }

  getEstimatedDuration(id: string): number {
    const durations = this.estimatedDurations.get(id);
    if (!durations?.length) return 0;


    return durations.reduce((acc, dur, idx) => 
      acc + (dur * (idx + 1)), 0) / durations.reduce((acc, _, idx) => 
      acc + (idx + 1), 0);
  }

  startLoading(config: LoadingConfig) {
    const state: LoadingState = {
      status: 'loading',
      startTime: performance.now(),
      progress: 0,
      dependencies: new Set(config.dependencies)
    };

    this.loadingStates.set(config.id, state);
    this.notifySubscribers();
    this.simulateProgress(config);
  }

  private simulateProgress(config: LoadingConfig) {
    const estimatedDuration = config.estimatedDuration || 
      this.getEstimatedDuration(config.id) || 
      1000; 

    const updateProgress = () => {
      const state = this.loadingStates.get(config.id);
      if (!state || state.status !== 'loading') return;

      const elapsed = performance.now() - (state.startTime || 0);
      const progress = Math.min(0.9, elapsed / estimatedDuration);

      state.progress = progress;
      this.notifySubscribers();

      if (progress < 0.9) {
        requestAnimationFrame(updateProgress);
      }
    };

    requestAnimationFrame(updateProgress);
  }

  completeLoading(id: string) {
    const state = this.loadingStates.get(id);
    if (!state) return;

    state.status = 'loaded';
    state.progress = 1;
    state.duration = performance.now() - (state.startTime || 0);

    this.updateEstimatedDuration(id, state.duration);
    this.notifySubscribers();
  }

  subscribe(callback: (states: Map<string, LoadingState>) => void) {
    this.subscribers.add(callback);
    return () => this.subscribers.delete(callback);
  }

  private notifySubscribers() {
    this.subscribers.forEach(callback => callback(this.loadingStates));
  }
}
Enter fullscreen mode Exit fullscreen mode

The React Integration:

To make this system truly powerful, I created a hook-based API that made implementation feel natural to other developers:

import { useState, useEffect, useCallback, createContext, useContext } from 'react';

const LoadingContext = createContext<LoadingOrchestrator | null>(null);

export function useLoading(config: LoadingConfig) {
  const orchestrator = useContext(LoadingContext);
  const [state, setState] = useState<LoadingState | null>(null);

  useEffect(() => {
    if (!orchestrator) return;

    const unsubscribe = orchestrator.subscribe((states) => {
      const currentState = states.get(config.id);
      if (currentState) setState(currentState);
    });

    return () => unsubscribe();
  }, [orchestrator, config.id]);

  const startLoading = useCallback(() => {
    orchestrator?.startLoading(config);
  }, [orchestrator, config]);

  const completeLoading = useCallback(() => {
    orchestrator?.completeLoading(config.id);
  }, [orchestrator, config.id]);

  return {
    state,
    startLoading,
    completeLoading
  };
}

export function LoadingBoundary({ 
  children, 
  config, 
  fallback 
}: LoadingBoundaryProps) {
  const { state } = useLoading(config);

  if (!state || state.status === 'loading') {
    return (
      <div className="loading-boundary">
        {fallback || <DefaultLoadingFallback progress={state?.progress} />}
      </div>
    );
  }

  return children;
}
Enter fullscreen mode Exit fullscreen mode

The Innovation:

What makes this system special is its ability to:

  1. Learn and Adapt
  • Maintains a moving average of load times
  • Adjusts progress animations based on historical data
  • Handles dependency chains intelligently
  1. Preserve Context

Instead of wiping the UI clean during loads, we;

  • Maintain spatial stability using skeleton layouts
  • Preserve previously loaded data when refreshing
  • Animate between loading and loaded states seamlessly
  1. Manage Cognitive Load
  • Progressive disclosure of loading states (avoid spinner floods)
  • Intelligent grouping of related loading operations
  • Predictive loading based on user interaction patterns

The Subtle Details:
My favorite part was crafting the micro-interactions:

import { animate, spring } from '@motionone/animation';

export class LoadingAnimationController {
  private elements: Map<string, HTMLElement> = new Map();
  private animations: Map<string, Animation> = new Map();

  register(id: string, element: HTMLElement) {
    this.elements.set(id, element);


    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            this.startAnimation(id);
          } else {
            this.pauseAnimation(id);
          }
        });
      },
      { threshold: 0.1 }
    );

    observer.observe(element);
  }

  private async startAnimation(id: string) {
    const element = this.elements.get(id);
    if (!element) return;

    const shimmer = document.createElement('div');
    shimmer.classList.add('loading-shimmer');
    element.appendChild(shimmer);

    const animation = animate(
      shimmer,
      {
        transform: ['translateX(-100%)', 'translateX(100%)'],
        opacity: [0, 1, 0]
      },
      {
        duration: 1.5,
        easing: spring({ stiffness: 100, damping: 30 }),
        repeat: Infinity
      }
    );

    this.animations.set(id, animation);
  }

  private pauseAnimation(id: string) {
    const animation = this.animations.get(id);
    animation?.pause();
  }

  updateProgress(id: string, progress: number) {
    const element = this.elements.get(id);
    if (!element) return;


    animate(
      element,
      { '--progress': `${progress * 100}%` },
      { 
        duration: 300,
        easing: spring({ 
          stiffness: 400,
          damping: progress > 0.8 ? 60 : 30 // More damping near completion
        })
      }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The Impact:

  • 47% reduction in perceived loading time
  • 23% decrease in premature page abandonment
  • 92% of users reported the app feeling "more responsive"
  • Zero increase in actual loading times
  • Improved error resilience through graceful fallbacks

Key Learnings:

Performance Perception is Complex
The fastest load isn't always the best experience. Sometimes, a slightly longer load with better feedback creates a more satisfying interaction.

System Design is UX Design
Technical architecture decisions directly impact user experience. The way we structure our loading states affects how users perceive application performance.

Devil's in the Details
Small touches matter: the spring tension in a progress bar, the timing of a skeleton loader, the sequencing of content appearance – they all contribute to the overall feel of quality.

This project changed how I think about loading states. They're not just technical necessities but opportunities for thoughtful interaction design. I'm now working on open-sourcing a refined version of this system, hoping to help other developers create more delightful loading experiences.
The code is complex, the concepts are nuanced, but the goal is simple: respect our users' time and attention by making every moment, even the loading ones, count.

Top comments (0)