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');
}
};
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>
);
}
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];
}
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...';
}
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>
);
}
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.













