Error Handling Architecture
Building resilient UIs with error boundaries and retry logic
Error Handling Architecture in Relay
Master sophisticated error handling patterns in Relay applications with free flashcards and spaced repetition practice. This lesson covers error boundary design, mutation error strategies, network failure recovery, and user feedback systemsβessential concepts for building resilient GraphQL-powered React applications.
Welcome to Error Handling Architecture π»
Welcome to one of the most critical aspects of production-ready Relay applications! Error handling isn't just about catching exceptionsβit's about creating resilient architectures that gracefully degrade, provide meaningful feedback, and recover intelligently. In this comprehensive lesson, you'll learn how to design error boundaries that protect your component trees, implement sophisticated mutation error handling with optimistic updates and rollbacks, manage network failures with retry strategies, and build user-friendly error notification systems.
Relay's architecture provides powerful primitives for error handling, but knowing how to compose them effectively requires understanding several interconnected patterns. We'll explore field-level errors versus global errors, the difference between recoverable and fatal failures, and how to maintain application state consistency when things go wrong. By the end of this lesson, you'll have a complete mental model for error handling that scales from simple form validation to complex distributed system failures.
Core Concepts: The Error Handling Landscape πΊοΈ
Understanding Error Categories in Relay
Relay applications encounter errors across multiple dimensions. Understanding these error taxonomies is crucial for designing appropriate handling strategies:
Network-Level Errors occur during transportβconnection timeouts, DNS failures, server unavailability. These are typically transient and benefit from retry strategies with exponential backoff.
GraphQL-Level Errors appear in the response errors array. These include validation failures, authorization issues, and business logic violations. They're often recoverable with user intervention (correcting input, re-authenticating).
Field-Level Errors use GraphQL's error extension model to associate problems with specific fields. This enables granular error handlingβdisplaying validation messages next to form fields rather than showing generic error banners.
Application-Level Errors happen in your React componentsβrendering errors, state management bugs, unexpected null values. React Error Boundaries catch these to prevent complete application crashes.
βββββββββββββββββββββββββββββββββββββββββββββββββββ β ERROR HANDLING LAYERS β βββββββββββββββββββββββββββββββββββββββββββββββββββ€ β β β π± UI Layer β β β Error Boundaries, User Feedback β β βοΈ Component Layer β β β Query/Mutation Hooks, Local State β β π Relay Layer β β β Store, Network, Normalization β β π Network Layer β β β Fetch, Retry, Timeout Logic β β π GraphQL Server β β β Resolver Errors, Schema Validation β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ
Error Boundary Architecture
React Error Boundaries are class components (or use react-error-boundary library) that catch rendering errors in child components. In Relay applications, strategic boundary placement prevents cascading failures:
// Strategic error boundary placement
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RelayEnvironmentProvider environment={environment}>
{/* Critical boundary: protects entire app */}
<ErrorBoundary FallbackComponent={ModuleFallback}>
<MainContent />
</ErrorBoundary>
{/* Separate boundary: sidebar can fail independently */}
<ErrorBoundary FallbackComponent={SidebarFallback}>
<Sidebar />
</ErrorBoundary>
</RelayEnvironmentProvider>
</ErrorBoundary>
);
}
Granular boundaries isolate failures to specific features. If a user profile widget crashes, the rest of the dashboard remains functional. This is the Bulkhead Pattern from distributed systems applied to UI architecture.
π‘ Pro Tip: Place error boundaries at feature boundaries, not around every component. Over-segmentation creates visual inconsistency; under-segmentation causes catastrophic failures.
Mutation Error Handling Patterns
Mutations present unique challenges because they modify state. A failed mutation might leave your UI in an inconsistent state if not handled properly. Relay provides several mechanisms:
Optimistic Updates assume success and immediately update the UI. If the mutation fails, Relay automatically rolls back to the previous state:
const [commitMutation, isMutationInFlight] = useMutation(graphql`
mutation UpdateUserMutation($input: UpdateUserInput!) {
updateUser(input: $input) {
user {
id
name
email
}
errors {
field
message
}
}
}
`);
function handleUpdate(userData) {
commitMutation({
variables: { input: userData },
optimisticResponse: {
updateUser: {
user: { ...userData, id: userId },
errors: null
}
},
onCompleted: (response) => {
if (response.updateUser.errors) {
// Handle field-level errors
displayFieldErrors(response.updateUser.errors);
} else {
showSuccessToast('Profile updated!');
}
},
onError: (error) => {
// Handle network/server errors
showErrorToast('Failed to update. Please try again.');
logErrorToService(error);
}
});
}
Notice the two-tier error handling: onError for infrastructure failures, checking errors field for business logic failures. This distinction is crucial.
Updater Functions provide fine-grained control over store updates. When mutations fail, you can implement custom rollback logic:
commitMutation({
variables: { input },
updater: (store) => {
const payload = store.getRootField('createPost');
const newPost = payload.getLinkedRecord('post');
const root = store.getRoot();
const posts = root.getLinkedRecords('posts');
// Manually prepend new post
root.setLinkedRecords([newPost, ...posts], 'posts');
},
onError: (error) => {
// Relay automatically reverts optimistic updates
// Additional cleanup if needed
}
});
Network Failure and Retry Strategies
Network failures are inevitable in production. Exponential backoff with jitter prevents thundering herd problems:
import { Network } from 'relay-runtime';
function createNetworkWithRetry() {
return Network.create(async (operation, variables) => {
const maxRetries = 3;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: operation.text, variables })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, attempt) * 1000;
// Add jitter: Β±25%
const jitter = delay * 0.25 * (Math.random() - 0.5);
await sleep(delay + jitter);
}
}
}
throw lastError;
});
}
Jitter prevents synchronized retries when many clients fail simultaneouslyβa common cause of cascading failures.
β οΈ Critical Distinction: Not all errors should trigger retries. Idempotent operations (queries, safe mutations) can retry safely. Non-idempotent operations (payment processing, order creation) require different strategies like idempotency keys:
function commitPaymentMutation(paymentData) {
const idempotencyKey = generateUUID();
commitMutation({
variables: {
input: { ...paymentData, idempotencyKey }
},
// Network layer can safely retry - server deduplicates by key
});
}
Field-Level vs Global Error Handling
Modern GraphQL APIs return errors in two places: the top-level errors array for critical failures, and field-specific error objects for validation issues. Your architecture should handle both:
| Error Type | Location | Handling Strategy | User Experience |
|---|---|---|---|
| Field Validation | Mutation payload | Display next to input | "Email must be valid" |
| Authorization | Top-level errors | Redirect to login | Modal or page transition |
| Rate Limiting | Top-level errors | Exponential backoff | "Please try again in 30s" |
| Server Error | Top-level errors | Log & alert | "Something went wrong" |
Implementing discriminated error handling:
function handleMutationResponse(response) {
const { data, errors } = response;
// Check top-level errors first
if (errors && errors.length > 0) {
errors.forEach(error => {
if (error.extensions?.code === 'UNAUTHENTICATED') {
redirectToLogin();
} else if (error.extensions?.code === 'RATE_LIMITED') {
const retryAfter = error.extensions.retryAfter;
showRateLimitMessage(retryAfter);
} else {
logToErrorTracking(error);
showGenericError();
}
});
return;
}
// Check field-level errors
if (data?.updateUser?.errors) {
const fieldErrors = data.updateUser.errors.reduce((acc, err) => {
acc[err.field] = err.message;
return acc;
}, {});
displayFieldErrors(fieldErrors);
return;
}
// Success path
showSuccessMessage();
}
Example 1: Building a Resilient Form with Comprehensive Error Handling π
Let's build a complete user profile update form that handles every error scenario gracefully:
import { useMutation, graphql } from 'react-relay';
import { useState } from 'react';
const UpdateProfileMutation = graphql`
mutation ProfileFormMutation($input: UpdateProfileInput!) {
updateProfile(input: $input) {
user {
id
name
email
bio
}
fieldErrors {
field
message
}
}
}
`;
function ProfileForm({ user }) {
const [formData, setFormData] = useState({
name: user.name,
email: user.email,
bio: user.bio
});
const [fieldErrors, setFieldErrors] = useState({});
const [globalError, setGlobalError] = useState(null);
const [saveStatus, setSaveStatus] = useState('idle'); // idle | saving | success | error
const [commitMutation, isMutationInFlight] = useMutation(UpdateProfileMutation);
const handleSubmit = (e) => {
e.preventDefault();
// Clear previous errors
setFieldErrors({});
setGlobalError(null);
setSaveStatus('saving');
commitMutation({
variables: {
input: {
userId: user.id,
...formData
}
},
optimisticResponse: {
updateProfile: {
user: {
id: user.id,
...formData
},
fieldErrors: null
}
},
onCompleted: (response) => {
const { fieldErrors: errors } = response.updateProfile;
if (errors && errors.length > 0) {
// Convert array to field map
const errorMap = errors.reduce((acc, err) => {
acc[err.field] = err.message;
return acc;
}, {});
setFieldErrors(errorMap);
setSaveStatus('error');
} else {
setSaveStatus('success');
setTimeout(() => setSaveStatus('idle'), 3000);
}
},
onError: (error) => {
// Network or server error
setGlobalError(
'Unable to save changes. Please check your connection and try again.'
);
setSaveStatus('error');
// Log to error tracking service
console.error('Profile update failed:', error);
}
});
};
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear field error when user starts typing
if (fieldErrors[field]) {
setFieldErrors(prev => {
const updated = { ...prev };
delete updated[field];
return updated;
});
}
};
return (
<form onSubmit={handleSubmit}>
{globalError && (
<div className="error-banner" role="alert">
β οΈ {globalError}
</div>
)}
<div className="form-field">
<label htmlFor="name">Name</label>
<input
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
aria-invalid={!!fieldErrors.name}
aria-describedby={fieldErrors.name ? 'name-error' : undefined}
/>
{fieldErrors.name && (
<span id="name-error" className="field-error" role="alert">
{fieldErrors.name}
</span>
)}
</div>
<div className="form-field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
/>
{fieldErrors.email && (
<span id="email-error" className="field-error" role="alert">
{fieldErrors.email}
</span>
)}
</div>
<div className="form-field">
<label htmlFor="bio">Bio</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => handleChange('bio', e.target.value)}
aria-invalid={!!fieldErrors.bio}
aria-describedby={fieldErrors.bio ? 'bio-error' : undefined}
/>
{fieldErrors.bio && (
<span id="bio-error" className="field-error" role="alert">
{fieldErrors.bio}
</span>
)}
</div>
<button
type="submit"
disabled={isMutationInFlight}
>
{saveStatus === 'saving' && 'πΎ Saving...'}
{saveStatus === 'success' && 'β
Saved!'}
{saveStatus === 'error' && 'β Failed'}
{saveStatus === 'idle' && 'πΎ Save Changes'}
</button>
</form>
);
}
Key architectural decisions:
- Optimistic updates provide instant feedback for perceived performance
- Field-level error state enables granular feedback without re-rendering entire form
- Global vs field errors separated for appropriate user feedback
- Accessibility attributes (
aria-invalid,aria-describedby,role="alert") - Error clearing on user interaction prevents stale error messages
Example 2: Query Error Handling with Suspense Boundaries π
Relay's Suspense integration requires coordinated error handling between Error Boundaries and Suspense Boundaries:
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { usePreloadedQuery, graphql } from 'react-relay';
const DashboardQuery = graphql`
query DashboardQuery($userId: ID!) {
user(id: $userId) {
id
name
...ActivityFeed_user
...ProfileStats_user
}
}
`;
function DashboardErrorFallback({ error, resetErrorBoundary }) {
const isNetworkError = error.message.includes('Failed to fetch');
const isAuthError = error.message.includes('Unauthenticated');
if (isAuthError) {
return (
<div className="error-state">
<h2>π Authentication Required</h2>
<p>Please log in to view your dashboard.</p>
<button onClick={() => redirectToLogin()}>Go to Login</button>
</div>
);
}
if (isNetworkError) {
return (
<div className="error-state">
<h2>π Connection Error</h2>
<p>Unable to load dashboard. Check your internet connection.</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
return (
<div className="error-state">
<h2>β οΈ Something Went Wrong</h2>
<details>
<summary>Error details</summary>
<pre>{error.message}</pre>
</details>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
function DashboardContent({ queryRef }) {
const data = usePreloadedQuery(DashboardQuery, queryRef);
if (!data.user) {
// Handle null data (user deleted, etc.)
return (
<div className="empty-state">
<h2>User Not Found</h2>
<p>This user may have been deleted.</p>
</div>
);
}
return (
<div className="dashboard">
<h1>Welcome, {data.user.name}!</h1>
{/* Isolated error boundaries for independent sections */}
<div className="dashboard-grid">
<ErrorBoundary
FallbackComponent={SectionErrorFallback}
onReset={() => console.log('Stats reset')}
>
<Suspense fallback={<SkeletonStats />}>
<ProfileStats user={data.user} />
</Suspense>
</ErrorBoundary>
<ErrorBoundary
FallbackComponent={SectionErrorFallback}
onReset={() => console.log('Feed reset')}
>
<Suspense fallback={<SkeletonFeed />}>
<ActivityFeed user={data.user} />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
function SectionErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="section-error">
<p>β οΈ This section failed to load</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
export function Dashboard({ queryRef }) {
return (
<ErrorBoundary
FallbackComponent={DashboardErrorFallback}
onReset={() => window.location.reload()}
>
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent queryRef={queryRef} />
</Suspense>
</ErrorBoundary>
);
}
Architecture highlights:
- Nested error boundaries isolate failures to smallest reasonable unit
- Error discrimination provides context-appropriate fallbacks
- Suspense + Error Boundary coordination handles loading AND error states
- Reset mechanisms allow retry without full page reload
Example 3: Advanced Network Layer with Circuit Breaker Pattern β‘
For production resilience, implement a circuit breaker that prevents cascading failures:
import { Network } from 'relay-runtime';
class CircuitBreaker {
constructor({
failureThreshold = 5,
resetTimeout = 60000, // 1 minute
monitoringWindow = 120000 // 2 minutes
}) {
this.failureThreshold = failureThreshold;
this.resetTimeout = resetTimeout;
this.monitoringWindow = monitoringWindow;
this.state = 'CLOSED'; // CLOSED | OPEN | HALF_OPEN
this.failures = [];
this.nextRetryTime = null;
}
recordSuccess() {
this.failures = [];
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
console.log('π’ Circuit breaker closed');
}
}
recordFailure() {
const now = Date.now();
this.failures.push(now);
// Remove old failures outside monitoring window
this.failures = this.failures.filter(
time => now - time < this.monitoringWindow
);
if (this.failures.length >= this.failureThreshold) {
this.state = 'OPEN';
this.nextRetryTime = now + this.resetTimeout;
console.log('π΄ Circuit breaker opened');
}
}
async execute(operation) {
const now = Date.now();
if (this.state === 'OPEN') {
if (now < this.nextRetryTime) {
throw new Error(
`Circuit breaker is OPEN. Retry after ${this.nextRetryTime - now}ms`
);
}
// Transition to half-open for testing
this.state = 'HALF_OPEN';
console.log('π‘ Circuit breaker half-open');
}
try {
const result = await operation();
this.recordSuccess();
return result;
} catch (error) {
this.recordFailure();
throw error;
}
}
}
function createResilientNetwork() {
const circuitBreaker = new CircuitBreaker({
failureThreshold: 5,
resetTimeout: 60000
});
return Network.create(async (operation, variables) => {
return circuitBreaker.execute(async () => {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAuthToken()}`
},
body: JSON.stringify({
query: operation.text,
variables
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
// Check for GraphQL errors that indicate server issues
if (json.errors) {
const hasServerError = json.errors.some(
err => err.extensions?.code === 'INTERNAL_SERVER_ERROR'
);
if (hasServerError) {
throw new Error('GraphQL server error');
}
}
return json;
});
});
}
Circuit breaker states:
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β CIRCUIT BREAKER STATE MACHINE β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββ
β β
βΌ β
π’ CLOSED β
(Normal operation) β
β β
β failures β₯ threshold β
βΌ β
π΄ OPEN β
(Reject all requests) β success
β β
β timeout expires β
βΌ β
π‘ HALF_OPEN β
(Test with one request) ββββββββββ
β
β failure
βΌ
π΄ OPEN
This pattern prevents overwhelming a failing backend, giving it time to recover.
Example 4: User-Friendly Error Notification System π’
Coordinating error feedback across your application requires a centralized notification system:
import { createContext, useContext, useState, useCallback } from 'react';
const NotificationContext = createContext();
function NotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const addNotification = useCallback((notification) => {
const id = Date.now() + Math.random();
const newNotification = {
id,
type: 'info', // info | success | warning | error
duration: 5000,
dismissible: true,
...notification
};
setNotifications(prev => [...prev, newNotification]);
if (newNotification.duration) {
setTimeout(() => {
removeNotification(id);
}, newNotification.duration);
}
return id;
}, []);
const removeNotification = useCallback((id) => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const notifySuccess = useCallback((message, options = {}) => {
return addNotification({ type: 'success', message, ...options });
}, [addNotification]);
const notifyError = useCallback((message, options = {}) => {
return addNotification({
type: 'error',
message,
duration: 8000, // Errors stay longer
...options
});
}, [addNotification]);
const notifyNetworkError = useCallback(() => {
return notifyError(
'Network connection lost. Changes may not be saved.',
{ dismissible: false }
);
}, [notifyError]);
return (
<NotificationContext.Provider value={{
notifications,
addNotification,
removeNotification,
notifySuccess,
notifyError,
notifyNetworkError
}}>
{children}
<NotificationDisplay
notifications={notifications}
onDismiss={removeNotification}
/>
</NotificationContext.Provider>
);
}
function NotificationDisplay({ notifications, onDismiss }) {
return (
<div className="notification-container" aria-live="polite">
{notifications.map(notification => (
<div
key={notification.id}
className={`notification notification-${notification.type}`}
role="alert"
>
<span className="notification-icon">
{notification.type === 'success' && 'β
'}
{notification.type === 'error' && 'β'}
{notification.type === 'warning' && 'β οΈ'}
{notification.type === 'info' && 'βΉοΈ'}
</span>
<span className="notification-message">
{notification.message}
</span>
{notification.dismissible && (
<button
className="notification-dismiss"
onClick={() => onDismiss(notification.id)}
aria-label="Dismiss notification"
>
Γ
</button>
)}
</div>
))}
</div>
);
}
export function useNotifications() {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotifications must be used within NotificationProvider');
}
return context;
}
// Usage in components:
function SaveButton() {
const { notifySuccess, notifyError } = useNotifications();
const [commitMutation] = useMutation(SaveMutation);
const handleSave = () => {
commitMutation({
variables: { input: { /* ... */ } },
onCompleted: () => {
notifySuccess('Changes saved successfully!');
},
onError: (error) => {
notifyError('Failed to save changes. Please try again.');
}
});
};
return <button onClick={handleSave}>Save</button>;
}
System benefits:
- Centralized state prevents duplicate notifications
- Type-specific styling provides visual hierarchy
- Auto-dismissal with configurable timeouts
- Accessibility via
aria-liveandrole="alert" - Context API makes notifications available anywhere
Common Mistakes to Avoid β οΈ
Mistake 1: Ignoring Partial Success in Mutations
Problem: Treating mutations as binary success/failure when GraphQL can return both data and errors:
// β WRONG: Assumes mutual exclusivity
commitMutation({
onCompleted: (response) => {
showSuccess('Saved!');
},
onError: (error) => {
showError('Failed!');
}
});
Solution: Always check for field-level errors in successful responses:
// β
RIGHT: Handles partial success
commitMutation({
onCompleted: (response) => {
if (response.updateUser.errors?.length > 0) {
handleFieldErrors(response.updateUser.errors);
} else {
showSuccess('Saved!');
}
},
onError: (error) => {
showError('Network error. Please retry.');
}
});
Mistake 2: Overly Aggressive Error Boundaries
Problem: Wrapping every component in an error boundary creates jarring UX:
// β WRONG: Too granular
function UserProfile({ user }) {
return (
<div>
<ErrorBoundary><UserAvatar user={user} /></ErrorBoundary>
<ErrorBoundary><UserName user={user} /></ErrorBoundary>
<ErrorBoundary><UserBio user={user} /></ErrorBoundary>
</div>
);
}
Solution: Place boundaries at feature/module level:
// β
RIGHT: Logical boundaries
function Dashboard() {
return (
<div>
<ErrorBoundary fallback={<ProfileError />}>
<UserProfile />
</ErrorBoundary>
<ErrorBoundary fallback={<FeedError />}>
<ActivityFeed />
</ErrorBoundary>
</div>
);
}
Mistake 3: Not Providing Recovery Actions
Problem: Showing errors without actionable next steps:
// β WRONG: Dead end
function ErrorFallback({ error }) {
return <div>Error: {error.message}</div>;
}
Solution: Always provide recovery mechanisms:
// β
RIGHT: Actionable feedback
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>Try Again</button>
<button onClick={() => window.location.href = '/'}>Go Home</button>
<a href="/support">Contact Support</a>
</div>
);
}
Mistake 4: Leaking Sensitive Error Information
Problem: Exposing internal implementation details or stack traces to users:
// β WRONG: Exposes internals
function showError(error) {
alert(`Error: ${error.stack}`);
}
Solution: Log detailed errors, show generic messages:
// β
RIGHT: User-friendly + logging
function handleError(error) {
// Log full details for debugging
errorTrackingService.log(error);
// Show generic message
notifyUser('An error occurred. Please try again.');
}
Mistake 5: Not Handling Stale Data After Errors
Problem: Displaying outdated optimistic data after mutation failure:
// β WRONG: No cleanup on error
commitMutation({
optimisticResponse: { /* ... */ },
onError: () => {
showError('Failed');
// UI still shows optimistic data!
}
});
Solution: Rely on Relay's automatic rollback and force re-fetch if needed:
// β
RIGHT: Ensure fresh data
commitMutation({
optimisticResponse: { /* ... */ },
onError: () => {
// Relay auto-reverts optimistic update
showError('Failed');
// Optional: force refetch for critical data
refetch({ /* ... */ }, { fetchPolicy: 'network-only' });
}
});
Key Takeaways π―
Layered Error Handling: Design error handling at multiple levelsβnetwork, GraphQL, field, and UIβwith appropriate strategies for each.
Error Boundaries: Place React Error Boundaries at feature boundaries, not around every component. Balance resilience with user experience.
Mutation Complexity: Handle both network errors (
onError) and business logic errors (checking response payload). These require different user feedback.Optimistic Updates: Use optimistic responses for perceived performance, but rely on Relay's automatic rollback for consistency.
Retry Strategies: Implement exponential backoff with jitter for transient failures. Use idempotency keys for non-idempotent operations.
User Feedback: Provide context-appropriate error messages with actionable recovery options. Never leave users in a dead-end state.
Circuit Breakers: Protect your application from cascading failures by implementing circuit breakers in the network layer.
Field-Level Errors: Associate validation errors with specific inputs for better UX. Clear errors when users start correcting them.
Accessibility: Use
aria-live,role="alert", andaria-invalidfor screen reader support. Error handling is a accessibility concern.Monitoring: Log errors to tracking services for visibility into production issues. Balance detail (for debugging) with privacy (for users).
π Quick Reference Card: Error Handling Checklist
| Concern | Implementation |
|---|---|
| Network Failures | Retry with exponential backoff + jitter |
| Mutation Errors | Check both onError AND response.errors |
| Component Crashes | Error Boundaries at feature boundaries |
| Optimistic Updates | Rely on automatic rollback, force refetch if needed |
| Validation Errors | Display next to fields, clear on user input |
| Auth Failures | Redirect to login with return URL |
| Rate Limiting | Show retry-after time, implement backoff |
| User Feedback | Toast notifications with icons, auto-dismiss |
| Logging | Full details to service, generic message to user |
| Recovery | Always provide retry/home/support options |
π Further Study
- Relay Error Handling Documentation - Official guide to mutation error patterns
- Martin Fowler: Circuit Breaker Pattern - Comprehensive explanation of circuit breakers in distributed systems
- React Error Boundaries - Official React documentation on error boundary implementation