DEV Community

Cover image for Building a Loading State That Doesn't Feel Slow — Lessons From an AI Generation Tool
Aon infotech
Aon infotech

Posted on

Building a Loading State That Doesn't Feel Slow — Lessons From an AI Generation Tool

Generation tools have a UX problem most apps don't: a genuine 8-15 second wait between action and result. Get the loading state wrong and users perceive the tool as slow even when the actual processing time is reasonable.

Here's what I learned building the loading experience for generate AI images for free.


The Problem With a Plain Spinner

The default instinct is a spinning loader. It works, technically. But it communicates almost nothing — no sense of progress, no indication anything specific is happening, just "wait."

Users staring at a generic spinner for 10 seconds report the wait as longer than users watching something that suggests progress, even when the actual duration is identical. This is a well-documented perception effect, and it matters disproportionately for generation tools where the wait is unavoidable.


Three Things That Changed Perceived Speed

1. Immediate state change on click

The button needs to respond the instant it's clicked — not after a network request confirms anything.

const [status, setStatus] = useState('idle');

const handleGenerate = async () => {
  setStatus('starting'); // Instant — before any API call

  try {
    const result = await submitGeneration(prompt);
    setStatus('processing');
    await pollUntilComplete(result.jobId);
    setStatus('complete');
  } catch {
    setStatus('error');
  }
};
Enter fullscreen mode Exit fullscreen mode

The gap between click and first visual feedback should be milliseconds, regardless of how long the actual generation takes afterward.

2. A skeleton that matches final dimensions

Instead of a spinner floating in empty space, show a placeholder shaped exactly like the output will be.

function LoadingSkeleton({ aspectRatio }) {
  const ratioClass = {
    '1:1': 'aspect-square',
    '16:9': 'aspect-video',
    '9:16': 'aspect-[9/16]',
  }[aspectRatio];

  return (
    <div className={`w-full ${ratioClass} rounded-2xl 
      bg-gradient-to-br from-neutral-100 to-neutral-200 
      dark:from-neutral-800 dark:to-neutral-900
      animate-pulse relative overflow-hidden`}>
      <div className="absolute inset-0 flex items-center 
        justify-center">
        <span className="text-sm text-neutral-400">
          Generating...
        </span>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This does two things: it eliminates layout shift when the real image arrives, and it gives users something more substantial to look at than an empty spinner.

3. Subtle progressive messaging

Static text ("Loading...") for 10 seconds feels stagnant. Rotating through a few status messages — even generic ones — creates a sense of progress.

const loadingMessages = [
  'Processing your prompt...',
  'Generating image...',
  'Almost there...',
];

function useRotatingMessage(isLoading) {
  const [index, setIndex] = useState(0);

  useEffect(() => {
    if (!isLoading) {
      setIndex(0);
      return;
    }

    const interval = setInterval(() => {
      setIndex(prev => 
        Math.min(prev + 1, loadingMessages.length - 1)
      );
    }, 3000);

    return () => clearInterval(interval);
  }, [isLoading]);

  return loadingMessages[index];
}
Enter fullscreen mode Exit fullscreen mode

Important caveat: don't fake specificity you don't have. If you don't actually know what stage the process is in, rotating generic-but-plausible messages is honest. Claiming false precision ("Optimizing pixel array...") when nothing like that is happening erodes trust once users notice the pattern repeats identically every time.


Handling the Variable Wait Time Honestly

Generation time isn't always consistent — cold starts, server load, and prompt complexity all affect duration. A loading state that assumes a fixed duration breaks when reality doesn't match.

// Avoid: assumes a fixed timeline
useEffect(() => {
  setTimeout(() => setMessage('Almost done!'), 8000);
}, []);

// Better: respond to actual elapsed time without
// promising a specific completion point
function useElapsedFeedback(isLoading) {
  const [elapsed, setElapsed] = useState(0);

  useEffect(() => {
    if (!isLoading) {
      setElapsed(0);
      return;
    }
    const interval = setInterval(() => {
      setElapsed(prev => prev + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, [isLoading]);

  if (elapsed < 5) return 'Generating your image...';
  if (elapsed < 15) return 'Still working on it...';
  return 'This is taking longer than usual...';
}
Enter fullscreen mode Exit fullscreen mode

The thresholds adjust messaging based on what's actually happening rather than assuming every generation takes the same time.


Error States Deserve the Same Care

A failed generation after a 10-second wait is frustrating. How you communicate the failure affects whether users try again or leave.

function ErrorState({ onRetry }) {
  return (
    <div className="flex flex-col items-center gap-3 p-6 
      text-center">
      <p className="text-sm text-neutral-500">
        Something went wrong with that generation.
      </p>
      <button
        onClick={onRetry}
        className="text-sm font-medium text-orange-500 
          hover:text-orange-600"
      >
        Try again
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Specific, actionable, no blame language, immediate retry option. Avoid technical error codes in user-facing messaging — "Error 500" means nothing to most users and feels worse than a plain-language explanation.


Measuring Whether It Actually Helped

Perceived speed improvements are hard to measure directly, but a few proxy metrics worked as indicators:

Metric Before After
Users abandoning during generation Higher Lower
Repeat generations per session Lower Higher
Support messages about "slow" Present Rare

None of these prove causation precisely, but the direction was consistent with the perception research this approach is based on.


TL;DR

  • Change button state instantly on click, before any network confirmation
  • Use a skeleton matching final output dimensions, not a generic spinner
  • Rotate honest, generic progress messages rather than static text or false specificity
  • Base messaging thresholds on elapsed time, not assumed fixed durations
  • Error states need the same UX care as success states — clear, actionable, blame-free The complete prompt and generation flow this powers is at pixova.io if you want to see it in practice.

What's your approach to loading states for genuinely slow operations? Curious how others have solved this.

Top comments (0)