Error Handling That Respects Your Users
`500 Internal Server Error`
That's what my trading dashboard showed for 2 hours while I was debugging a Supabase connection timeout. Two hours of users seeing a white page with a generic error message. No explanation, no guidance, no way to know if it was their fault or mine.
I fixed the bug in 20 minutes. Fixing the error handling took the rest of the day. And it was more important.
The Principles
1. Tell Users WHAT Happened (Not How)
```typescript // Terrible: exposes internals, helps nobody "Error: ECONNREFUSED 127.0.0.1:5432"
// Bad: accurate but unhelpful "Database connection failed"
// Good: user-centric, actionable "We're having trouble loading your data. This usually resolves in a few minutes. Your data is safe." ```
Users don't need to know your database is down. They need to know their data is safe and when to try again.
2. Tell Users WHAT TO DO Next
Every error message should have an action:
```typescript const errorResponses = { NETWORK_ERROR: { title: "Connection issue", message: "Check your internet and try again.", action: { label: "Retry", onClick: () => refetch() } }, AUTH_EXPIRED: { title: "Session expired", message: "Please log in again to continue.", action: { label: "Log In", onClick: () => redirect('/login') } }, RATE_LIMITED: { title: "Too many requests", message: "Please wait a moment and try again.", action: { label: "Retry in 30s", onClick: () => setTimeout(refetch, 30000) } }, SERVER_ERROR: { title: "Something went wrong", message: "We're looking into it. Try refreshing the page.", action: { label: "Refresh", onClick: () => location.reload() } } }; ```
3. Degrade Gracefully, Don't Crash Completely
When one part of the dashboard fails, don't blank the whole page:
```tsx function DashboardPage() { return (
<ErrorBoundary fallback={<AlertsError />}>
<ActiveAlerts />
</ErrorBoundary>
<ErrorBoundary fallback={<ChartError />}>
<PriceChart />
</ErrorBoundary>
</div>
); } ```
If the price chart API is down, the portfolio summary and alerts still work. Each section fails independently.
4. Log for Engineers, Display for Humans
```typescript try { const data = await fetchMarketData(symbol); return data; } catch (error) { // For engineers: full context in server logs console.error('Market data fetch failed', { symbol, error: error.message, stack: error.stack, timestamp: new Date().toISOString(), retryCount: attempt });
// For users: simple, helpful message throw new UserFacingError( "Market data temporarily unavailable", "Showing last known prices. Live data will resume automatically." ); } ```
The engineer gets the stack trace, the symbol, and the retry count. The user gets a human sentence and reassurance.
The Error Hierarchy
Not all errors are equal. I categorize them:
| Category | User Message | Engineering Action |
|---|---|---|
| Transient (network, timeout) | "Try again in a moment" | Auto-retry with backoff |
| User error (bad input) | "Please check [field]" | Validate before submit |
| Auth (expired, revoked) | "Please log in again" | Redirect to login |
| System (our fault) | "We're on it" | Alert on-call, show cached data |
| Catastrophic (data loss risk) | "Contact support: [email]" | Page on-call immediately |
Each category has a different tone, different recovery path, and different urgency.
The Test
My error handling test: deliberately break every external dependency (database, API, auth) and check:
- Does the user see a helpful message? (Not a stack trace)
- Does the user have a clear next action? (Not a dead end)
- Does the rest of the app still function? (Not a white screen)
- Did the engineers get alerted with full context? (Not a silent failure)
If all four pass, the error handling is solid. If any fail, I'm disrespecting either my users or my team.
Want to see this in action?
Check out the projects and case studies behind these articles.