Loading States: Designing Better Experiences While Users Wait
When you open an app, you expect it to respond quickly. Tap a button, click a link, submit a form - you want instant feedback. But sometimes the app needs to fetch data, download assets, or run background work.
If nothing shows up while that happens, users get confused. Did it work? Did the app freeze? Should I refresh?
That’s where loading states come in. They’re the small details that reassure people something is happening. Done well, they make apps feel smooth and polished. Done poorly (or left out completely), they make apps feel broken.
In this guide, we’ll go step by step through different types of loading states, when to use them, and how to code them.
Why Loading States Matter
Imagine opening a banking app, tapping “View Transactions,” and getting a blank white screen for 3 seconds. Most people will panic. Did the app crash? Did they lose money?
Now imagine instead seeing:
A spinner telling you data is loading.
Or greyed-out transaction rows shaped like the final data (skeleton screen).
Or even a progress bar showing 60% complete.
These tiny changes transform the experience from uncertain to reassuring.
A good loading state:
Shows progress is happening. Users know the app is alive.
Sets expectations. They know roughly how long to wait.
Reduces frustration. People are more patient when they see feedback.
The Simplest Loading State: Text
The bare minimum: just show “Loading...”
<p>Loading...</p>
It may not be pretty, but it solves the biggest problem: silence.
When to use this:
Internal tools.
Debugging.
Temporary placeholders until you add polish.
Even with plain text, you can improve accessibility with ARIA:
<paria-live="polite">Loading user data...</p>
This way, screen readers announce it to the user.
Spinners
Spinners are the classic loading indicator. Everyone recognizes them.
This creates placeholders shaped like the real data.
Why skeletons feel fast: They give users a preview of what’s coming. Even if data takes 3 seconds, the brain accepts it better because the layout is visible.
Best use cases:
News feeds.
Dashboards.
Lists of content.
Shimmer Effects
Take skeletons and add animation, and you get shimmer loaders. A gradient sweeps across the skeleton, giving the sense of motion.
React makes it easy to show loading states while components fetch data.
importReact,{Suspense}from'react';constUserProfile = React.lazy(()=>import('./UserProfile'));functionApp(){return(<Suspensefallback={<p>Loading user profile...</p>}><UserProfile/></Suspense>);}
Here, the fallback <p> shows until the component loads.
Making Loading States Accessible
Accessibility matters. A spinner with no text is meaningless to someone using a screen reader.
This way, the screen reader announces the message.
Real-World Examples
Slack: Shows skeleton channels and messages when switching workspaces.
GitHub: Uses spinners for short actions (like starring repos) and progress bars for uploads.
Stripe Dashboard: Uses skeleton loaders for metrics, making it feel instant.
Each company picks a pattern depending on the context.
Common Mistakes to Avoid
Infinite spinners. If it takes long, show progress or give a cancel option.
Blank screens. Always show something, even plain text.
Removing loaders too early. Don’t flash content in and out.
Accessibility gaps. Always pair visuals with text or ARIA roles.
Advanced Patterns for Loading States
Basic spinners and skeletons are useful, but advanced techniques can make your app feel even smoother and smarter. Let’s explore some patterns that go beyond the basics.
Optimistic UI
With optimistic UI, you don’t wait for the server before updating the interface. You assume success, update the UI instantly, and then reconcile if something goes wrong.
Example: Liking a post
functionLikeButton({postId}){const[liked,setLiked] = React.useState(false);consthandleClick = ()=>{setLiked(true);// update instantlyfetch(`/api/like/${postId}`,{method:'POST'})
.catch(()=>{setLiked(false);// roll back if error});};return(<buttononClick={handleClick}>{liked ? "❤️ Liked" : "♡ Like"}</button>);}
The user sees the heart fill instantly. If the request fails, it rolls back.
Why it works:
Feels instant, no spinner required.
Great for actions that are usually successful.
Caution:
Requires rollback logic.
Can be confusing if errors are frequent.
Prefetching
Prefetching means loading data before the user asks for it, so when they do, it feels instant.
The user sees a blurred thumbnail instantly, then the sharp image fades in.
Real-world use:
Medium pioneered this with progressive image loading.
YouTube loads video titles first, thumbnails after.
Why it works:
Reduces perceived wait time.
Gives structure quickly.
Combining Patterns
You don’t have to pick one. Many apps combine these techniques:
Optimistic UI for likes and follows.
Skeletons for structured data.
Prefetching for predictable navigation.
Background placeholders for images and media.
The result: an app that feels fast in almost every situation.
Hands-on Demo: Mini Dashboard That Feels Fast
Below is a small project you can paste into an index.html file and open in a browser. It shows three patterns in one place - skeletons, optimistic UI, and prefetch. It also includes a basic progress bar with ARIA so it is screen reader friendly.
1) Files
You can keep it all in one file to try it fast. Then split later if you want.
<!doctype html>
<htmllang="en"><head><metacharset="utf-8"/><metaname="viewport"content="width=device-width, initial-scale=1"/><title>Fast-feel Dashboard</title><style>:root {--bg:#0f172a;--card:#111827;--muted:#e5e7eb;--accent:#3b82f6;--ok:#16a34a;--err:#dc2626;}body{margin:0;font:16px/1.4system-ui,-apple-system,SegoeUI,Roboto,sans-serif;background:var(--bg);color:#fff;}
.wrap {max-width:960px;margin:0auto;padding:24px;}h1{margin:0016px;font-size:28px;}
.row {display:grid;grid-template-columns:1fr1fr;gap:16px;}
.card {background:var(--card);border-radius:12px;padding:16px;box-shadow:01px0rgba(255,255,255,0.05)inset;}
.muted {color:var(--muted);}/* Skeletons */
.skeleton {position:relative;overflow:hidden;background:#1f2937;border-radius:8px;}
.sk-line {height:14px;margin:8px0;}
.sk-thumb {height:48px;width:48px;border-radius:50%;}
.skeleton::after {content:"";position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,rgba(255,255,255,0.06),transparent);animation:shimmer1.2sinfinite;}@keyframes shimmer {to{transform:translateX(100%);}}/* Spinner */
.spinner {width:32px;height:32px;border:3pxsolid#374151;border-top-color:var(--accent);border-radius:50%;animation:spin1slinearinfinite;margin-right:8px;}@keyframes spin {to{transform:rotate(360deg);}}/* Progress bar */
.progress {height:10px;background:#1f2937;border-radius:999px;overflow:hidden;}
.bar {height:100%;width:0%;background:var(--accent);transition:width.2s;}/* Buttons */
.btn {display:inline-flex;align-items:center;gap:8px;padding:8px12px;border:1pxsolid#334155;border-radius:10px;background:#0b1220;color:#fff;cursor:pointer;}
.btn:hover {background:#0a1020;}
.btn[disabled]{opacity:.6;cursor:not-allowed;}
.btn-like[data-liked="true"]{border-color:var(--ok);color:var(--ok);}ul{list-style:none;padding:0;margin:0;}li{display:flex;gap:12px;align-items:center;padding:10px0;border-bottom:1pxsolid#1f2937;}
.thumb {width:48px;height:48px;border-radius:50%;object-fit:cover;}</style></head><body><divclass="wrap"><h1>Dashboard</h1><divclass="row"><!-- Feed card with skeletons that swap to real content --><sectionclass="card"id="feed"><h2>Latest posts</h2><divid="feed-skeleton"><divclass="skeleton sk-thumb"></div><divclass="skeleton sk-line"style="width:70%"></div><divclass="skeleton sk-line"style="width:50%"></div><divclass="skeleton sk-line"style="width:60%"></div></div><ulid="feed-list"hidden></ul></section><!-- Actions card shows optimistic UI like a like button --><sectionclass="card"id="actions"><h2>Actions</h2><buttonclass="btn btn-like"id="likeBtn"aria-pressed="false"data-liked="false">♡ Like</button><pclass="muted"id="likeMsg"role="status"aria-live="polite"></p><divstyle="margin-top:16px"><buttonclass="btn"id="uploadBtn">Simulate upload</button><divclass="progress"style="margin-top:8px"aria-label="Upload progress"role="progressbar"aria-valuemin="0"aria-valuemax="100"aria-valuenow="0"><divclass="bar"id="bar"></div></div><pclass="muted"id="upMsg"role="status"aria-live="polite"></p></div></section></div><sectionclass="card"style="margin-top:16px"><h2>Navigation</h2><pclass="muted">Hover a link to prefetch the page so it opens fast.</p><navid="nav"><aclass="btn"href="/fake/page-1">Page 1</a><aclass="btn"href="/fake/page-2">Page 2</a><aclass="btn"href="/fake/page-3">Page 3</a></nav><pid="navMsg"class="muted"role="status"aria-live="polite"></p></section></div><script>// Simulated API helpersconstsleep = (ms)=>newPromise(r=>setTimeout(r,ms));asyncfunctionfakeFetch(url){awaitsleep(900 + Math.random()*800);// variable delayif(url.includes('error') && Math.random() < 0.2)thrownewError('Network');return{ok:true,json:async()=>([{id:1,name:'Ayesha',avatar:'https://picsum.photos/seed/a/96',text:'Learning day by day.'},{id:2,name:'Rafi',avatar:'https://picsum.photos/seed/b/96',text:'Shipping a small feature.'},{id:3,name:'Maya',avatar:'https://picsum.photos/seed/c/96',text:'Coffee and code.'}])};}// 1) Skeletons -> real contentasyncfunctionloadFeed(){constsk = document.getElementById('feed-skeleton');constlist = document.getElementById('feed-list');constres = awaitfakeFetch('/api/posts');constitems = awaitres.json();list.innerHTML = items.map(item=>`
<li>
<img class="thumb" src="${item.avatar}" alt="${item.name} avatar" />
<div>
<strong>${item.name}</strong>
<div class="muted">${item.text}</div>
</div>
</li>`).join('');sk.hidden = true;list.hidden = false;}// 2) Optimistic like button with rollbackconstlikeBtn = document.getElementById('likeBtn');constlikeMsg = document.getElementById('likeMsg');likeBtn.addEventListener('click',async()=>{constliked = likeBtn.dataset.liked === 'true';// optimistic updatelikeBtn.dataset.liked = String(!liked);likeBtn.setAttribute('aria-pressed',String(!liked));likeBtn.textContent = !liked ? '❤️ Liked' : '♡ Like';likeMsg.textContent = !liked ? 'Saved like' : 'Removed like';try{constres = awaitfakeFetch('/api/like');if(!res.ok)thrownewError('Server');}catch{// rollbacklikeBtn.dataset.liked = String(liked);likeBtn.setAttribute('aria-pressed',String(liked));likeBtn.textContent = liked ? '❤️ Liked' : '♡ Like';likeMsg.textContent = 'Could not save. Try again.';}});// 3) Accessible progress bar for uploadsconstuploadBtn = document.getElementById('uploadBtn');constbar = document.getElementById('bar');constprog = document.querySelector('[role="progressbar"]');constupMsg = document.getElementById('upMsg');uploadBtn.addEventListener('click',async()=>{uploadBtn.disabled = true;upMsg.textContent = 'Uploading...';for(leti = 0;i <= 100;i += Math.floor(Math.random()*12)+5){awaitsleep(180);bar.style.width = i + '%';prog.setAttribute('aria-valuenow',String(i));}bar.style.width = '100%';prog.setAttribute('aria-valuenow','100');upMsg.textContent = 'Upload complete';awaitsleep(600);bar.style.width = '0%';prog.setAttribute('aria-valuenow','0');uploadBtn.disabled = false;upMsg.textContent = '';});// 4) Prefetch on hoverconstnavMsg = document.getElementById('navMsg');document.querySelectorAll('#nav a').forEach(a=>{a.addEventListener('mouseenter',async()=>{navMsg.textContent = `Prefetching ${a.getAttribute('href')}...`;try{awaitfakeFetch(a.getAttribute('href'));navMsg.textContent = 'Ready.';}catch{navMsg.textContent = 'Could not prefetch.';}});});// kick things offloadFeed();</script></body></html>
What to look for
The feed shows skeletons first, then real content.
The like button switches state instantly. If the request fails, it rolls back.
The upload button runs a progress bar that updates aria-valuenow so screen readers can track progress.
Links prefetch on hover so navigation feels fast.
2) React Version (Optional)
Here is a minimal React example that uses the same ideas - a skeleton list, an optimistic like button, and a simulated upload with an accessible progress bar.
importReact,{useEffect,useState}from'react';constsleep = (ms:number)=>newPromise(r=>setTimeout(r,ms));constfakeFetch = async(url:string)=>{awaitsleep(700 + Math.random()*600);return{ok:true,json:async()=>[{id:1,name:'Ayesha',avatar:'https://picsum.photos/seed/a/96',text:'Learning day by day.'},{id:2,name:'Rafi',avatar:'https://picsum.photos/seed/b/96',text:'Shipping a small feature.'},{id:3,name:'Maya',avatar:'https://picsum.photos/seed/c/96',text:'Coffee and code.'},]}};exportdefaultfunctionApp(){const[items,setItems] = useState<any[]|null>(null);const[liked,setLiked] = useState(false);const[progress,setProgress] = useState(0);useEffect(()=>{(async()=>{constres = awaitfakeFetch('/api/posts');setItems(awaitres.json());})();},[]);asyncfunctiontoggleLike(){constprev = liked;setLiked(!prev);// optimistictry{constres = awaitfakeFetch('/api/like');if(!res.ok)thrownewError('x');}catch{setLiked(prev);}}asyncfunctionsimulateUpload(){for(leti=0;i<=100;i+= Math.floor(Math.random()*12)+5){awaitsleep(120);setProgress(i);}setProgress(100);awaitsleep(500);setProgress(0);}return(<divstyle={{padding:16,color:'#fff',background:'#0f172a',minHeight:'100vh'}}><h1>Dashboard</h1><sectionstyle={{background:'#111827',padding:16,borderRadius:12,marginBottom:16}}><h2>Latest posts</h2>{!items ? (<div><divstyle={{height:48,width:48,borderRadius:24,background:'#1f2937',marginBottom:8}}/><divstyle={{height:14,width:'70%',background:'#1f2937',marginBottom:8}}/><divstyle={{height:14,width:'50%',background:'#1f2937',marginBottom:8}}/></div>) : (<ulstyle={{listStyle:'none',padding:0}}>{items.map(it=>(<likey={it.id}style={{display:'flex',gap:12,alignItems:'center',padding:'10px 0',borderBottom:'1px solid #1f2937'}}><imgsrc={it.avatar}alt={`${it.name} avatar`}width={48}height={48}style={{borderRadius:24}}/><div><strong>{it.name}</strong><divstyle={{color:'#e5e7eb'}}>{it.text}</div></div></li>))}</ul>)}</section><sectionstyle={{background:'#111827',padding:16,borderRadius:12}}><h2>Actions</h2><buttontype="button"onClick={toggleLike}aria-pressed={liked}style={{padding:'8px 12px',borderRadius:10}}>{liked ? '❤️ Liked' : '♡ Like'}</button><divstyle={{marginTop:16}}><buttontype="button"onClick={simulateUpload}style={{padding:'8px 12px',borderRadius:10}}>Simulate upload</button><divrole="progressbar"aria-label="Upload progress"aria-valuemin={0}aria-valuemax={100}aria-valuenow={progress}style={{height:10,background:'#1f2937',borderRadius:999,overflow:'hidden',marginTop:8}}><divstyle={{height:'100%',width:progress+'%',background:'#3b82f6',transition:'width .2s'}}/></div></div></section></div>);}
What happens:
The feed shows skeletons, then real posts.
The like button updates instantly, then rolls back if needed.
Upload simulates a progress bar with proper aria-valuenow updates.
Links prefetch on hover so navigation feels fast.
Pattern Picker
Use this quick guide to pick the right UI for the expected wait.
Expected wait
Unknown duration
Known duration
Structured content
< 1s
No loader, keep layout stable
No loader
Avoid loaders
1–3s
Spinner + text
Indeterminate progress bar
Skeleton
3–10s
Skeleton or progress with hints
Determinate progress bar
Skeleton + shimmer
> 10s
Progress bar + cancel or background task
Progress bar + ETA, offer cancel
Skeleton, consider backgrounding
Accessibility Deep Dive
Use aria-live="polite" for non-critical updates, aria-live="assertive" for high priority messages.
Always label progress bars with aria-label or aria-labelledby, and keep aria-valuenow in sync.
Do not disable focus outlines. If you use a full-page loader, ensure the focus is trapped inside it and returned to the triggering control when loading finishes.
Respect reduced motion. Turn off shimmer and heavy animations under prefers-reduced-motion.
For buttons that trigger loading, set aria-busy="true" on the container and update the button text to include the action, for example Saving….
Error-friendly Loading
Time out long requests and swap the loader for a message with Retry and Cancel.
Distinguish between empty and error states. Empty: “No items yet.” Error: “Could not load items.”
Keep the layout stable to avoid content shift when swapping loaders for content.
Performance Notes
Lazy load large images and non-critical JS with loading="lazy" and dynamic imports.
Preconnect to critical domains with <link rel="preconnect">.
Serve small blurred placeholders to reduce perceived cost of images.
Testing and Checks
Test tab order during loading. The focused element should not disappear.
Use Lighthouse and Web Vitals. Watch LCP and CLS when loaders swap in and out.
Simulate slow 3G in DevTools to check that loaders actually render.
Common Mistakes to Avoid
Infinite spinners with no end state.
Blank screens while data loads.
Removing loaders too early (causing flicker).
Ignoring accessibility (e.g. spinners with no text).
Summary Checklist
✅ Use text loaders as the simplest fallback.
✅ Use spinners for short, uncertain waits.
✅ Use skeletons and shimmers for structured data.
✅ Use progress bars when you know the steps.
✅ Always make loaders accessible with ARIA and live regions.
✅ Respect reduced-motion for shimmer effects.
✅ Try optimistic UI, prefetching, and background placeholders for advanced polish.
Designing good loading states is not decoration. It’s UX. They shape how people feel about your app’s speed, reliability, and trustworthiness.