Building Resilient Loading States: Beyond Simple Spinners
You know that moment when you click "delete" on something, and it disappears from the screen, but then you refresh the page and it's still there? Or worse, you see a "deleted" label on something that's actually still being created in the background?
I've been there. We all have. And it's frustrating for users.
Recently, I was working on a feature where users could create content that loads in the background. The problem? Sometimes the content wasn't immediately available in the API, even though we'd already shown it on screen. When we checked if something existed, we'd get a 404 error, and our code would assume it was deleted. But really, it was just still being created.
That's when I realized: simple loading states aren't enough. We need resilient loading states that handle edge cases, network failures, and the messy reality of background operations.
In this article, I'll walk you through the patterns I used to build loading states that actually work in production. We'll cover polling with max timeouts, retry mechanisms, caching strategies, and error recovery. By the end, you'll have a toolkit for handling loading states that don't break when things go wrong.
The Problem with Simple Loading States
Let me explain the issue. You have a feature where users can create items. The creation happens on the server, and it takes time. Maybe it takes a few seconds, maybe the server needs to process it, or maybe there's a queue involved.
Here's what happens with a basic approach:
const checkIfItemExists = async (itemId: string) => {
try {
const response = await fetch(`/api/items/${itemId}`);
if (response.ok) {
return true; // Item exists
} else if (response.status === 404) {
return false; // Item doesn't exist (must be deleted)
}
} catch (error) {
// Network error - assume it doesn't exist?
return false;
}
};
The problem? This code can't tell the difference between:
| Situation | What the Code Thinks | What's Actually Happening |
|---|---|---|
| Item was deleted | Item doesn't exist | ✅ Correct |
| Item is still being created | Item doesn't exist | ❌ Wrong - it's loading |
| Network error | Item doesn't exist | ❌ Wrong - we don't know |
| Server is down | Item doesn't exist | ❌ Wrong - temporary issue |
So when you check for an item that was just created, you might get a 404, and your code assumes it's deleted. You show a "deleted" label, the user gets confused, and everyone's frustrated.
Why Simple Spinners Fail
The typical loading state looks like this:
const [loading, setLoading] = useState(false);
const [item, setItem] = useState(null);
useEffect(() => {
setLoading(true);
fetchItem(id)
.then(setItem)
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (!item) return <div>Item not found</div>;
return <ItemDisplay item={item} />;
This works fine when everything goes smoothly. But what about when:
- The network is slow?
- The request times out?
- The item is still being created?
- There's a temporary server error?
Your users see "Item not found" when really, the item just needs a few more seconds. Or they see a spinner forever because the network is having issues.
We need something better.
The Solution: Polling with Max Timeout
My first thought was to use a "grace period" - if an item was just created, give it a few seconds before assuming it doesn't exist. But this approach has a fatal flaw: we're guessing how long creation takes.
What if the API needs 10 seconds, but we set a 5-second grace period? We'd mark items as "deleted" when they're still being created.
The real solution: Don't guess. Keep polling until we get a definitive answer, with a maximum timeout as a safety net.
Instead of relying on a grace period guess, we should:
- Keep polling - Continue checking until we get a definitive answer
- Set a maximum timeout - Only give up after a reasonable time (e.g., 30-60 seconds)
- Grace period is optional - It's just a mental model, not the core logic
Here's a better implementation that doesn't rely on guessing:
interface ItemCheckOptions {
itemId: string;
createdAt: number;
maxWaitTimeMs?: number; // Maximum time to wait (default: 30000ms = 30 seconds)
}
const checkItemWithPolling = async (
options: ItemCheckOptions
): Promise<'exists' | 'loading' | 'deleted'> => {
const {
itemId,
createdAt,
maxWaitTimeMs = 30000 // 30 seconds
} = options;
const now = Date.now();
const age = now - createdAt;
// If we've waited too long, assume it's deleted
if (age > maxWaitTimeMs) {
return 'deleted';
}
try {
const response = await fetch(`/api/items/${itemId}`);
if (response.ok) {
return 'exists'; // Found it!
}
if (response.status === 404) {
// Not found yet - but we're still within max wait time
// Keep polling (handled by the component)
return 'loading';
}
// Other error - treat as loading
return 'loading';
} catch (error) {
// Network error - if within max timeout, keep trying
if (age < maxWaitTimeMs) {
return 'loading';
}
return 'deleted';
}
};
Key insight: We don't need to guess how long creation takes. We just keep checking until either:
- ✅ Item appears (success!)
- ❌ Max timeout expires (assume deleted)
How This Works Better
Here's the improved timeline:
| Time | What Happens | Status |
|---|---|---|
| 0s | User creates item | Creating... |
| 5s | Check API - 404 error | Still loading (keep polling) |
| 10s | Check API - 404 error | Still loading (keep polling) |
| 15s | Check API - 200 success | ✅ Item exists! |
| 35s | Check API - 404 error | ❌ Item deleted (max timeout reached) |
Notice: We don't give up at 5 seconds. We keep checking until either success or the max timeout.
Using Polling in Components
Here's how you'd use this in a React component with polling:
const ItemStatus = ({ itemId, createdAt }: Props) => {
const [status, setStatus] = useState<'exists' | 'loading' | 'deleted'>('loading');
useEffect(() => {
const checkStatus = async () => {
const result = await checkItemWithPolling({
itemId,
createdAt,
maxWaitTimeMs: 30000 // Stop after 30 seconds
});
setStatus(result);
// Stop polling if item exists or is confirmed deleted
if (result === 'exists' || result === 'deleted') {
return false; // Signal to stop polling
}
return true; // Continue polling
};
// Initial check
checkStatus();
// Poll every second while loading
const interval = setInterval(async () => {
const shouldContinue = await checkStatus();
if (!shouldContinue) {
clearInterval(interval);
}
}, 1000);
return () => clearInterval(interval);
}, [itemId, createdAt]);
if (status === 'loading') {
return <div>Creating item...</div>;
}
if (status === 'deleted') {
return <div>Item was deleted</div>;
}
return <div>Item exists!</div>;
};
Understanding the Time Windows
Here's how it works:
| Time Period | Purpose | Example Value |
|---|---|---|
| Polling Interval | How often we check the API | 1 second |
| Max Wait Time | When to give up and assume deleted | 30 seconds |
The key insight: We don't need to guess how long creation takes. We just keep polling until either:
- ✅ The item appears (success!)
- ❌ The max wait time expires (assume deleted)
Choosing the right max wait time:
- Should be longer than your longest expected creation time
- If items sometimes take 20 seconds, use 30-60 seconds
- This gives slow operations time to complete without waiting forever
Handling Network Failures
Network requests fail. Sometimes it's temporary, sometimes the server is slow. For critical checks, you might want simple retry logic.
Here's a basic approach:
const fetchWithRetry = async (
url: string,
maxRetries: number = 3
): Promise<Response> => {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
// Retry on server errors (5xx) but not client errors (4xx)
if (response.status >= 500 && attempt < maxRetries) {
// Wait a bit before retrying
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
}
throw lastError || new Error('Max retries exceeded');
};
When to retry:
- ✅ Network errors (usually temporary)
- ✅ Server errors (5xx - temporary issues)
- ❌ Client errors (4xx - user or code problem)
- ❌ Creating new items (might create duplicates)
Caching for Performance
If you're checking the same items multiple times, caching can dramatically improve performance and reduce server load.
Simple Cache Implementation
Here's a basic cache for item existence checks:
interface CacheEntry {
result: boolean;
timestamp: number;
ttl: number; // Time to live in milliseconds
}
class ItemExistenceCache {
private cache = new Map<string, CacheEntry>();
private defaultTtl = 5 * 60 * 1000; // 5 minutes
get(itemId: string): boolean | null {
const entry = this.cache.get(itemId);
if (!entry) {
return null; // Not in cache
}
const age = Date.now() - entry.timestamp;
if (age > entry.ttl) {
this.cache.delete(itemId);
return null; // Expired
}
return entry.result;
}
set(itemId: string, exists: boolean, ttl?: number): void {
this.cache.set(itemId, {
result: exists,
timestamp: Date.now(),
ttl: ttl || this.defaultTtl
});
}
invalidate(itemId: string): void {
this.cache.delete(itemId);
}
clear(): void {
this.cache.clear();
}
}
const itemCache = new ItemExistenceCache();
Using Cache in Your Checks
Now we can use the cache to avoid unnecessary API calls:
const checkItemWithCache = async (
itemId: string,
conversationId: string
): Promise<boolean> => {
// Check cache first
const cacheKey = `${conversationId}:${itemId}`;
const cached = itemCache.get(cacheKey);
if (cached !== null) {
return cached; // Return cached result
}
// Not in cache, fetch from API
try {
const response = await fetch(`/api/items/${itemId}`);
const exists = response.ok;
// Cache the result
itemCache.set(cacheKey, exists);
return exists;
} catch (error) {
// On error, don't cache (we don't know the real state)
throw error;
}
};
Cache Benefits
Here's how caching helps:
| Scenario | Without Cache | With Cache |
|---|---|---|
| First check | API call | API call |
| Second check (same item) | API call | ✅ Instant (from cache) |
| Third check (same item) | API call | ✅ Instant (from cache) |
| After 5 minutes | API call | API call (cache expired) |
Cache Invalidation
Knowing when to clear your cache is important:
// Clear cache when item is deleted
const deleteItem = async (itemId: string) => {
await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
itemCache.invalidate(itemId);
};
// Don't cache immediately when item is created
const createItem = async (item: Item) => {
const newItem = await fetch('/api/items', {
method: 'POST',
body: JSON.stringify(item)
}).then(r => r.json());
// Don't cache immediately - might still be creating
// Cache will be saved on first successful check
};
Error States and Recovery
When things go wrong, users need to know what happened and how to fix it. Vague error messages like "Something went wrong" don't help anyone.
Clear Error Messages
const getErrorMessage = (error: Error, context: string): string => {
if (error.message.includes('network')) {
return 'Network error. Please check your connection and try again.';
}
if (error.message.includes('timeout')) {
return 'Request timed out. The server might be slow right now.';
}
if (error.message.includes('404')) {
return `${context} not found. It may have been deleted.`;
}
if (error.message.includes('500')) {
return 'Server error. Please try again in a moment.';
}
return `Unable to load ${context}. Please try again.`;
};
Error Message Examples
Here's how different errors are shown to users:
| Error Type | Technical Message | User-Friendly Message |
|---|---|---|
| Network error | NetworkError: Failed to fetch |
"Network error. Please check your connection and try again." |
| Timeout | TimeoutError: Request timed out |
"Request timed out. The server might be slow right now." |
| 404 | 404: Not Found |
"Item not found. It may have been deleted." |
| 500 | 500: Internal Server Error |
"Server error. Please try again in a moment." |
Error Recovery UI
Give users a way to recover:
const ItemWithErrorRecovery = ({ itemId }: Props) => {
const [status, setStatus] = useState<'loading' | 'exists' | 'error'>('loading');
const [error, setError] = useState<Error | null>(null);
const { retry, canRetry } = useItemChecker(itemId);
if (status === 'loading') {
return <div>Loading item...</div>;
}
if (status === 'error') {
return (
<div className="error-container">
<p>{getErrorMessage(error!, 'Item')}</p>
{canRetry && (
<button onClick={retry}>
Try Again
</button>
)}
</div>
);
}
return <ItemDisplay itemId={itemId} />;
};
Putting It All Together
Here's a simplified example that combines the core patterns:
interface ItemCheckerOptions {
itemId: string;
createdAt?: number;
maxWaitTimeMs?: number;
}
const useItemChecker = (options: ItemCheckerOptions) => {
const {
itemId,
createdAt,
maxWaitTimeMs = 30000 // 30 seconds max
} = options;
const [status, setStatus] = useState<'loading' | 'exists' | 'deleted' | 'error'>('loading');
const [error, setError] = useState<Error | null>(null);
const checkItem = useCallback(async () => {
// Check if we've exceeded max wait time
if (createdAt) {
const age = Date.now() - createdAt;
if (age > maxWaitTimeMs) {
setStatus('deleted');
return;
}
}
try {
const response = await fetch(`/api/items/${itemId}`);
if (response.ok) {
setStatus('exists');
} else if (response.status === 404) {
// Check if still within max wait time
if (createdAt) {
const age = Date.now() - createdAt;
if (age < maxWaitTimeMs) {
// Still within max wait time - keep polling
setStatus('loading');
return;
}
}
// Exceeded max wait time - assume deleted
setStatus('deleted');
} else {
setStatus('error');
setError(new Error(`Server returned ${response.status}`));
}
} catch (err) {
// Network error - check if still within max wait time
if (createdAt) {
const age = Date.now() - createdAt;
if (age < maxWaitTimeMs) {
setStatus('loading');
return;
}
}
setStatus('error');
setError(err as Error);
}
}, [itemId, createdAt, maxWaitTimeMs]);
useEffect(() => {
checkItem();
// Re-check every second if still loading
if (status === 'loading') {
const interval = setInterval(checkItem, 1000);
return () => clearInterval(interval);
}
}, [checkItem, status]);
const retry = useCallback(() => {
setError(null);
checkItem();
}, [checkItem]);
return {
status,
error,
retry,
isLoading: status === 'loading',
exists: status === 'exists'
};
};
This hook combines:
- ✅ Polling with max timeout
- ✅ Clear error states
- ✅ Manual retry capability
Best Practices
Here are some lessons I learned while implementing these patterns:
1. Always Show a Loading State
Don't leave users guessing. Even if you're not sure, show something.
Bad:
if (!item) return null; // User sees nothing
Good:
if (!item) return <div>Loading...</div>; // User knows something is happening
2. Handle Edge Cases
404s aren't always "not found" - they might mean "not created yet" or "not accessible".
3. Provide Recovery Mechanisms
Give users a way to retry or refresh when things go wrong.
4. Cache When Possible
But be smart about clearing cache. Old data is worse than no data.
5. Use Appropriate Timeouts
Choose your max wait time based on your API's behavior:
Max Wait Time:
- Should be longer than your longest expected creation time
- Gives slow operations time to complete
- Only assume deletion after this time expires
| Typical Creation Time | Recommended Max Wait Time |
|---|---|
| 1-2 seconds | 20 seconds |
| 3-5 seconds | 30 seconds |
| 5-10 seconds | 60 seconds |
Too short, and you'll give up on slow operations. Too long, and users wait unnecessarily.
6. Log Errors for Debugging
But don't show technical errors to users. Translate them into user-friendly messages.
7. Test Failure Scenarios
Network failures, timeouts, server errors - test them all.
Common Pitfalls
Here's what to avoid:
| Mistake | Why It's Bad | How to Fix |
|---|---|---|
| Assuming 404 means deleted | Item might still be creating | Use polling with max timeout |
| Not handling network errors | Network failures are common | Add retry logic |
| Forgetting to clear cache | Old cache causes confusion | Clear cache on updates |
| Retrying operations that create things | Might create duplicates | Only retry safe operations |
| No user feedback | Users don't know what's happening | Always show loading/error states |
Conclusion
Simple loading states work fine for simple cases. But real applications need resilient loading states that handle edge cases, network failures, and the messy reality of background operations.
By combining polling with max timeouts, retry mechanisms, caching, and clear error handling, you can build loading states that actually work in production. Your users will thank you for it.
The patterns I've shared here are tested and production-ready. Start with polling for new content, add retries for network failures, implement caching for performance, and always provide clear error messages with recovery options.
Remember: the goal isn't to handle every possible edge case perfectly. It's to handle the common cases well and gracefully handle problems when things go wrong.
Have you implemented similar patterns? What challenges did you face? I'd love to hear about your experiences in the comments below.
Top comments (0)