Michael Ouroumis logoichael Ouroumis

Error Handling Best Practices in JavaScript: Leveraging try/catch & Custom Errors

Flat-style illustration of the JavaScript logo next to a try/catch code snippet and a shield icon representing robust error handling

TL;DR:
Effective error handling in JavaScript means more than wrapping code in try/catch. Use targeted try/catch blocks, preserve stack traces, create custom Error subclasses for domain-specific issues, and handle async errors with async/await and promise chains. Avoid empty catches and silencing errors—fail fast and surface meaningful messages.


Why Robust Error Handling Matters

JavaScript apps—whether front-end UI or back-end services—face failures from:

  • Invalid user input
  • Network/API failures
  • Unexpected data formats
  • Logic bugs or resource limits

Without proper handling, uncaught errors lead to silent failures, broken UI, or unhandled promise rejections that crash your process.


1. Using try/catch Effectively

Scope your try blocks narrowly

Don’t wrap your entire function—only the lines that can throw:

// ❌ Too broad try { const data = await fetchData() processData(data) renderUI(data) } catch (err) { console.error(err) } // ✅ Scoped to the risky operation let data try { data = await fetchData() } catch (err) { console.error('Failed to load data:', err) showErrorMessage('Could not load data.') return } // Safe to process and render processData(data) renderUI(data)

Always rethrow or handle

An empty catch hides bugs. Either:

  • Handle: show user-friendly message or retry.
  • Rethrow: if you can’t recover:
try { validateConfig(config) } catch (err) { if (err instanceof ConfigError) { console.warn('Invalid config, using defaults.') config = getDefaultConfig() } else { // Unknown error—surface it throw err } }

2. Crafting Custom Error Classes

Built-in errors (TypeError, ReferenceError, etc.) aren’t always descriptive. Define domain errors:

class ValidationError extends Error { constructor(field, message) { super(`${field}: ${message}`) this.name = 'ValidationError' this.field = field // Maintains proper stack trace if (Error.captureStackTrace) { Error.captureStackTrace(this, ValidationError) } } } // Usage: function validateUser(user) { if (!user.email.includes('@')) { throw new ValidationError('email', 'must contain @') } }

Benefits

  • Clarity: err.name === 'ValidationError'
  • Metadata: carry extra properties (field)
  • Stack trace: pinpoint throw site

3. Async Error Handling

Promises with .catch()

Always end promise chains:

fetch('/api/data') .then((res) => res.json()) .then(processData) .catch((err) => { console.error('API error:', err) showErrorNotification() })

async/await

Wrap awaited calls:

async function loadAndRender() { let items try { items = await fetchItems() } catch (err) { console.error('loadAndRender failed:', err) displayError('Could not load items.') return } renderItems(items) }

Tip: Node.js ≥ 15 warns on unhandled promise rejections—treat them as exceptions.


4. Best Practices & Anti-Patterns

Practice✅ Good Use❌ Anti-Pattern
Empty catchN/Acatch (e) {}
Logging & contextconsole.error('User fetch failed', err);console.error(err);
Fail-fast on unexpected errorsRethrow unknown errorsSwallow all errors after a generic handler
Graceful degradationShow fallback UI when recoverableLet the app crash without user feedback
Centralized error middleware (Node)Express error handler capturing all next(err) callsNo global handler; crashes the server

5. Global & Framework-Level Handling

  • Browser:

    window.addEventListener('error', (event) => { logError(event.error) showToast('Unexpected error occurred.') }) window.addEventListener('unhandledrejection', (event) => { logError(event.reason) })
  • Node.js / Express:

    app.use((err, req, res, next) => { console.error(err) res.status(500).json({ error: 'Internal server error' }) })

Final Thoughts

  • Be intentional: catch only what you can handle.
  • Be descriptive: custom errors with names and metadata.
  • Be proactive: global handlers for uncaught and rejected promises.
  • Be user-friendly: surface clear messages and recovery paths.

With these practices, your JavaScript code will be more resilient, maintainable, and easier to debug—turning runtime blunders into opportunities for graceful recovery.

Enjoyed this post? Share it: