DEV Community

Hasnaat Iftikhar
Hasnaat Iftikhar

Posted on

Building Resilient Loading States: Beyond Simple Spinners

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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} />;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Keep polling - Continue checking until we get a definitive answer
  2. Set a maximum timeout - Only give up after a reasonable time (e.g., 30-60 seconds)
  3. 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';
  }
};
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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');
};
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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.`;
};
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

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'
  };
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Good:

if (!item) return <div>Loading...</div>; // User knows something is happening
Enter fullscreen mode Exit fullscreen mode

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)