Skip to main content
What happens when something goes wrong in your JavaScript code? How do you prevent one small error from crashing your entire application? How do you give users helpful feedback instead of a cryptic error message?
// Without error handling - your app crashes
const userData = JSON.parse('{ invalid json }')  // SyntaxError!

// With error handling - you stay in control
try {
  const userData = JSON.parse('{ invalid json }')
} catch (error) {
  console.log('Could not parse user data:', error.message)
  // Show user a friendly message, use default data, etc.
}
Error handling is how you detect, respond to, and recover from errors in your code. JavaScript provides the try...catch...finally statement for synchronous errors and special patterns for handling async errors in Promises and async/await.
What you’ll learn in this guide:
  • The try...catch...finally statement and when to use each block
  • The Error object and its properties (name, message, stack)
  • Built-in Error types: TypeError, ReferenceError, SyntaxError, and more
  • How to throw your own errors with meaningful messages
  • Creating custom Error classes for better error categorization
  • Error handling patterns for async code
  • Global error handlers for catching uncaught errors
  • Common mistakes and real-world patterns
Helpful prerequisite: This guide covers async error handling briefly. For a deeper dive into async patterns, check out Promises and async/await first.

What is Error Handling in JavaScript?

Errors happen. Users enter invalid data, network requests fail, APIs return unexpected responses, and sometimes we just make typos. Error handling is your strategy for detecting, responding to, and recovering from these problems gracefully. In JavaScript, you use the try...catch statement to catch errors, the throw statement to create them, and the Error object to describe what went wrong. According to the Stack Overflow 2023 Developer Survey, debugging and error handling remain among the most time-consuming aspects of development, making robust error handling patterns a critical skill.

The Safety Net Analogy

Think of error handling like a trapeze act at a circus. The acrobat (your code) performs risky moves high above the ground. The safety net (your catch block) is there to catch them if they fall. And no matter what happens, the show must go on (your finally block).
┌─────────────────────────────────────────────────────────────────────────┐
│                       THE SAFETY NET ANALOGY                             │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│     try {                         TRAPEZE ACT                            │
│       riskyMove()                 ┌─────────┐                            │
│     }                             │ ACROBAT │  ← Your risky code         │
│                                   └────┬────┘                            │
│                                        │                                 │
│     catch (error) {                    ▼  FALLS!                         │
│       recover()              ═══════════════════════                     │
│     }                            SAFETY NET  ← Catches the error         │
│                                                                          │
│     finally {                   The show continues!                      │
│       cleanup()                 (runs no matter what)                    │
│     }                                                                    │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘
CircusJavaScriptPurpose
Trapeze acttry blockCode that might fail
Safety netcatch blockHandles the error if one occurs
Show continuesfinally blockCleanup that always runs
Acrobat fallsError is thrownSomething went wrong

The try/catch/finally Statement

The try...catch statement is JavaScript’s primary tool for handling errors. As MDN documents, this statement has been part of JavaScript since ECMAScript 3 (1999) and remains the standard mechanism for synchronous error handling. Here’s the full syntax:
try {
  // Code that might throw an error
  const result = riskyOperation()
  console.log(result)
  
} catch (error) {
  // Code that runs if an error is thrown
  console.error('Something went wrong:', error.message)
  
} finally {
  // Code that ALWAYS runs, error or not
  cleanup()
}

The try Block

The try block contains code that might throw an error. If an error occurs, execution immediately jumps to the catch block.
try {
  console.log('Starting...')      // Runs
  JSON.parse('{ bad json }')      // Error! Jump to catch
  console.log('This never runs')  // Skipped
}

The catch Block

The catch block receives the error object and handles it. This is where you log errors, show user messages, or attempt recovery.
try {
  const data = JSON.parse(userInput)
} catch (error) {
  // error contains information about what went wrong
  console.log(error.name)     // "SyntaxError"
  console.log(error.message)  // "Unexpected token b in JSON..."
  
  // You can recover gracefully
  const data = { fallback: true }
}
Optional catch binding: If you don’t need the error object, you can omit it (ES2019+):
try {
  JSON.parse(maybeJson)
} catch {
  // No (error) parameter needed if you don't use it
  return null
}

The finally Block

The finally block always runs, whether an error occurred or not. It’s perfect for cleanup code like closing connections or hiding loading spinners.
let isLoading = true

try {
  const data = await fetchData()
  displayData(data)
} catch (error) {
  showErrorMessage(error)
} finally {
  // This runs no matter what!
  isLoading = false
  hideLoadingSpinner()
}
finally runs even with return: If you return from a try or catch block, finally still executes before the function returns:
function example() {
  try {
    return 'from try'
  } finally {
    console.log('finally runs!')  // This still logs!
  }
}

example()  // Logs "finally runs!", then returns "from try"

try/catch Only Works Synchronously

This trips people up: try/catch won’t catch errors in callbacks that run later.
// ❌ WRONG - catch won't catch this error!
try {
  setTimeout(() => {
    throw new Error('Async error')
  }, 1000)
} catch (error) {
  console.log('This never runs')
}

// ✓ CORRECT - try/catch inside the callback
setTimeout(() => {
  try {
    throw new Error('Async error')
  } catch (error) {
    console.log('Caught:', error.message)
  }
}, 1000)
For async code, see the Async Error Handling section.

The Error Object

When an error occurs, JavaScript creates an Error object with information about what went wrong.

Error Properties

PropertyDescriptionExample
nameThe type of error"TypeError", "ReferenceError"
messageHuman-readable description"Cannot read property 'x' of undefined"
stackCall stack when error occurred (non-standard but widely supported)Shows file names, line numbers
causeOriginal error (ES2022+)Used for error chaining
try {
  undefinedVariable
} catch (error) {
  console.log(error.name)     // "ReferenceError"
  console.log(error.message)  // "undefinedVariable is not defined"
  console.log(error.stack)    // Full stack trace with line numbers
}
The stack property is essential for debugging. It shows exactly where the error occurred and the chain of function calls that led to it.

Built-in Error Types

JavaScript has several built-in error types. Knowing them helps you understand what went wrong and how to fix it. The ECMAScript specification defines seven native error types, each representing a different category of runtime problem.
Error TypeWhen It OccursCommon Cause
ErrorGeneric errorBase class, used for custom errors
TypeErrorWrong typenull.foo, calling non-function
ReferenceErrorInvalid referenceUsing undefined variable
SyntaxErrorInvalid syntaxBad JSON, missing brackets
RangeErrorValue out of rangenew Array(-1)
URIErrorBad URI encodingdecodeURIComponent('%')
AggregateErrorMultiple errorsPromise.any() all reject
Occurs when a value is not the expected type, like calling a method on null or undefined:
const user = null
console.log(user.name)  // TypeError: Cannot read property 'name' of null

const notAFunction = 42
notAFunction()  // TypeError: notAFunction is not a function
Fix: Check if values exist before using them:
console.log(user?.name)  // undefined (no error)
Occurs when you try to use a variable that hasn’t been declared:
console.log(userName)  // ReferenceError: userName is not defined
Common causes: Typos in variable names, forgetting to import, using variables before declaration.
Occurs when code has invalid syntax or when parsing invalid JSON:
JSON.parse('{ name: "John" }')  // SyntaxError: Unexpected token n
// JSON requires double quotes: { "name": "John" }

JSON.parse('')  // SyntaxError: Unexpected end of JSON input
Note: Syntax errors in your source code are caught at parse time, not runtime. try/catch only catches runtime SyntaxErrors like invalid JSON.
Occurs when a value is outside its allowed range:
new Array(-1)            // RangeError: Invalid array length
(1.5).toFixed(200)       // RangeError: precision out of range (max is 100)
'x'.repeat(Infinity)     // RangeError: Invalid count value

The throw Statement

The throw statement lets you create your own errors. When you throw, execution stops and jumps to the nearest catch block.
function divide(a, b) {
  if (b === 0) {
    throw new Error('Cannot divide by zero')
  }
  return a / b
}

try {
  const result = divide(10, 0)
} catch (error) {
  console.log(error.message)  // "Cannot divide by zero"
}

Always Throw Error Objects

Technically you can throw anything, but always throw Error objects. They include a stack trace for debugging.
// ❌ BAD - No stack trace, hard to debug
throw 'Something went wrong'
throw 404
throw { message: 'Error' }

// ✓ GOOD - Includes stack trace
throw new Error('Something went wrong')
throw new TypeError('Expected a string')
throw new RangeError('Value must be between 0 and 100')

Creating Meaningful Error Messages

Good error messages tell you what went wrong and ideally how to fix it:
// ❌ Vague
throw new Error('Invalid input')

// ✓ Specific
throw new Error('Email address is invalid: missing @ symbol')
throw new TypeError(`Expected string but got ${typeof value}`)
throw new RangeError(`Age must be between 0 and 150, got ${age}`)

Custom Error Classes

For larger applications, create custom error classes to categorize errors and add extra information.
class ValidationError extends Error {
  constructor(message) {
    super(message)
    this.name = 'ValidationError'
  }
}

class NetworkError extends Error {
  constructor(message, statusCode) {
    super(message)
    this.name = 'NetworkError'
    this.statusCode = statusCode
  }
}

The Auto-Naming Pattern

Instead of manually setting this.name in every class, use the constructor name:
class AppError extends Error {
  constructor(message, options) {
    super(message, options)
    this.name = this.constructor.name  // Automatically uses class name
  }
}

class ValidationError extends AppError {}
class DatabaseError extends AppError {}
class NetworkError extends AppError {}

// All have correct names automatically
throw new ValidationError('Invalid email')  // error.name === "ValidationError"

Using instanceof for Error Handling

Custom errors let you handle different error types differently:
try {
  await saveUser(userData)
} catch (error) {
  if (error instanceof ValidationError) {
    // Show validation message to user
    showFieldErrors(error.fields)
  } else if (error instanceof NetworkError) {
    // Network issue - maybe retry
    showRetryButton()
  } else {
    // Unknown error - log and show generic message
    console.error('Unexpected error:', error)
    showGenericError()
  }
}

Error Chaining with cause (ES2022+)

When catching and re-throwing errors, preserve the original error using the cause option:
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`)
    return await response.json()
  } catch (error) {
    // Wrap the original error with more context
    throw new Error(`Failed to load user ${userId}`, { cause: error })
  }
}

// Later, you can access the original error
try {
  await fetchUserData(123)
} catch (error) {
  console.log(error.message)       // "Failed to load user 123"
  console.log(error.cause.message) // Original fetch error
}

Async Error Handling

Error handling works differently with asynchronous code. Here’s a quick overview. For comprehensive coverage, see our Promises and async/await guides.

With Promises: .catch()

Use .catch() to handle errors in Promise chains:
fetch('/api/users')
  .then(response => response.json())
  .then(users => displayUsers(users))
  .catch(error => {
    // Catches errors from fetch, json parsing, or displayUsers
    console.error('Failed to load users:', error)
  })
  .finally(() => {
    hideLoadingSpinner()
  })

With async/await: try/catch

With async/await, use regular try/catch blocks:
async function loadUsers() {
  try {
    const response = await fetch('/api/users')
    const users = await response.json()
    return users
  } catch (error) {
    console.error('Failed to load users:', error)
    throw error  // Re-throw if caller should handle it
  }
}

The fetch() Trap: Check response.ok

This catches many developers off guard: fetch() doesn’t throw on HTTP errors like 404 or 500. It only throws on network failures.
// ❌ WRONG - This won't catch 404 or 500 errors!
try {
  const response = await fetch('/api/users/999')
  const user = await response.json()  // Might fail on error response
} catch (error) {
  // Only catches network errors, not HTTP errors
}

// ✓ CORRECT - Check response.ok
try {
  const response = await fetch('/api/users/999')
  
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`)
  }
  
  const user = await response.json()
} catch (error) {
  // Now catches both network AND HTTP errors
  console.error('Request failed:', error.message)
}
The #1 async mistake: Using forEach with async callbacks doesn’t work as expected. Use for...of for sequential or Promise.all for parallel. See our async/await guide for details.

Global Error Handlers

Global error handlers catch errors that slip through your try/catch blocks. They’re a safety net of last resort, not a replacement for proper error handling.

window.onerror - Synchronous Errors

Catches uncaught errors in the browser:
window.onerror = function(message, source, lineno, colno, error) {
  console.log('Uncaught error:', message)
  console.log('Source:', source, 'Line:', lineno)
  
  // Send to error tracking service
  logErrorToService(error)
  
  // Return true to prevent default browser error handling
  return true
}

unhandledrejection - Promise Rejections

Catches unhandled Promise rejections:
window.addEventListener('unhandledrejection', event => {
  console.warn('Unhandled promise rejection:', event.reason)
  
  // Prevent the default browser warning
  event.preventDefault()
  
  // Log to error tracking service
  logErrorToService(event.reason)
})
When to use global handlers:
  • Logging errors to a service like Sentry or LogRocket
  • Showing a generic “something went wrong” message
  • Tracking errors in production
Not for: Regular error handling. Always prefer specific try/catch blocks.

Common Mistakes

Mistake 1: Empty catch Blocks (Swallowing Errors)

// ❌ WRONG - Error is silently lost
try {
  riskyOperation()
} catch (error) {
  // Nothing here - you'll never know something failed
}

// ✓ CORRECT - At minimum, log the error
try {
  riskyOperation()
} catch (error) {
  console.error('Operation failed:', error)
}

Mistake 2: Catching Too Broadly

// ❌ WRONG - Hides programming bugs
try {
  processData(data)
  undefinedVriable  // Typo! This bug is now hidden
} catch (error) {
  return 'Something went wrong'
}

// ✓ CORRECT - Only catch expected errors
try {
  return JSON.parse(userInput)
} catch (error) {
  if (error instanceof SyntaxError) {
    return null  // Expected: invalid JSON
  }
  throw error  // Unexpected: re-throw
}

Mistake 3: Throwing Strings Instead of Errors

// ❌ WRONG - No stack trace
throw 'User not found'

// ✓ CORRECT - Has stack trace for debugging
throw new Error('User not found')

Mistake 4: Not Re-throwing When Needed

// ❌ WRONG - Caller doesn't know an error occurred
async function fetchData() {
  try {
    return await fetch('/api/data')
  } catch (error) {
    console.log('Error:', error)
    // Returns undefined - caller thinks it succeeded!
  }
}

// ✓ CORRECT - Re-throw or return meaningful value
async function fetchData() {
  try {
    return await fetch('/api/data')
  } catch (error) {
    console.log('Error:', error)
    throw error  // Let caller handle it
    // OR: return null with explicit meaning
  }
}

Mistake 5: Forgetting try/catch is Synchronous

// ❌ WRONG - Won't catch async errors
try {
  setTimeout(() => {
    throw new Error('Async error')  // Uncaught!
  }, 1000)
} catch (error) {
  console.log('Never runs')
}

// ✓ CORRECT - Put try/catch inside callback
setTimeout(() => {
  try {
    throw new Error('Async error')
  } catch (error) {
    console.log('Caught:', error.message)
  }
}, 1000)

Real-World Patterns

Retry Pattern

Automatically retry failed operations, useful for flaky network requests:
async function fetchWithRetry(url, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      return await response.json()
    } catch (error) {
      if (i === retries - 1) throw error  // Last attempt, give up
      
      // Wait before retrying (exponential backoff)
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)))
    }
  }
}

Validation Error Pattern

Collect multiple validation errors at once:
class ValidationError extends Error {
  constructor(errors) {
    super('Validation failed')
    this.name = 'ValidationError'
    this.errors = errors  // { email: "Invalid email", age: "Must be positive" }
  }
}

function validateUser(data) {
  const errors = {}
  
  if (!data.email?.includes('@')) {
    errors.email = 'Invalid email address'
  }
  if (data.age < 0) {
    errors.age = 'Age must be positive'
  }
  
  if (Object.keys(errors).length > 0) {
    throw new ValidationError(errors)
  }
}

// Usage
try {
  validateUser({ email: 'bad', age: -5 })
} catch (error) {
  if (error instanceof ValidationError) {
    // Show errors next to form fields
    Object.entries(error.errors).forEach(([field, message]) => {
      showFieldError(field, message)
    })
  }
}

Graceful Degradation

Try the ideal path, fall back to alternatives:
async function loadUserPreferences(userId) {
  try {
    // Try to fetch from API
    return await fetchFromApi(`/preferences/${userId}`)
  } catch (apiError) {
    console.warn('API unavailable, trying cache:', apiError.message)
    
    try {
      // Fall back to local storage
      const cached = localStorage.getItem(`prefs_${userId}`)
      if (cached) return JSON.parse(cached)
    } catch (cacheError) {
      console.warn('Cache unavailable:', cacheError.message)
    }
    
    // Fall back to defaults
    return { theme: 'light', language: 'en' }
  }
}

Key Takeaways

The key things to remember:
  1. Use try/catch for synchronous code — Wrap risky operations and handle errors appropriately
  2. try/catch is synchronous — It won’t catch errors in callbacks. Use .catch() for Promises or try/catch inside async functions
  3. Always throw Error objects, not strings — Error objects include stack traces that are essential for debugging
  4. Always check response.ok with fetchfetch() doesn’t throw on HTTP errors like 404 or 500
  5. Create custom Error classes — They help categorize errors and add context for better handling
  6. Use finally for cleanup — Code in finally always runs, perfect for hiding spinners or closing connections
  7. Don’t swallow errors — Empty catch blocks hide bugs. Always log or re-throw
  8. Use error.cause for chaining — Preserve original errors when wrapping them with more context
  9. Re-throw errors you can’t handle — If you catch an error you didn’t expect, re-throw it
  10. Use global handlers as a safety net — They’re for logging and tracking, not for regular error handling

Test Your Knowledge

Answer:try/catch only catches synchronous errors. If you have async code inside the try block (like setTimeout callbacks), errors won’t be caught.Promise .catch() catches Promise rejections, which are async. With async/await, you can use try/catch because await converts rejections to thrown errors.
// try/catch with async/await - works!
try {
  await fetch('/api/data')
} catch (error) {
  // Catches rejections because await converts them
}

// try/catch with callbacks - doesn't work!
try {
  setTimeout(() => { throw new Error() }, 1000)
} catch (error) {
  // Never runs - the error is thrown later
}
Answer:fetch() only throws on network failures (can’t reach the server). HTTP errors like 404 (Not Found) or 500 (Server Error) are valid HTTP responses, so fetch() resolves successfully.You must check response.ok to detect HTTP errors:
const response = await fetch('/api/users/999')

if (!response.ok) {
  // 404, 500, etc.
  throw new Error(`HTTP error: ${response.status}`)
}

const data = await response.json()
Answer:Error objects include a stack trace showing where the error occurred and the chain of function calls. Strings don’t have this information.
throw 'Something went wrong'  // No stack trace
throw new Error('Something went wrong')  // Has stack trace
The stack trace is essential for debugging, especially in production where you can’t use a debugger.
Answer:The finally block always runs, whether an error occurred or not, and even if there’s a return statement in try or catch. It’s ideal for cleanup code.
function example() {
  try {
    return 'success'
  } catch (error) {
    return 'error'
  } finally {
    console.log('Cleanup!')  // Always runs!
  }
}

example()  // Logs "Cleanup!" then returns "success"
Use it for: hiding loading spinners, closing connections, releasing resources.
Answer:Use instanceof to check the error type, or check error.name:
try {
  riskyOperation()
} catch (error) {
  if (error instanceof TypeError) {
    console.log('Type error:', error.message)
  } else if (error instanceof SyntaxError) {
    console.log('Syntax error:', error.message)
  } else {
    // Unknown error - re-throw it
    throw error
  }
}
This is especially useful with custom error classes:
if (error instanceof ValidationError) {
  showFormErrors(error.errors)
} else if (error instanceof NetworkError) {
  showOfflineMessage()
}
try {
  const result = riskyOperation()
} catch (e) {
  // Handle error
}

console.log(result)  // ???
Answer:result is scoped to the try block. It doesn’t exist outside of it, so console.log(result) throws a ReferenceError.Fix: Declare the variable outside the try block:
let result

try {
  result = riskyOperation()
} catch (e) {
  result = 'fallback value'
}

console.log(result)  // Works!

Frequently Asked Questions

return ends a function and passes a value to the caller. throw creates an error that unwinds the call stack until a catch block handles it. Use throw for exceptional conditions that the current function cannot resolve; use return for normal control flow, including returning error indicators like null or result objects.
No. Only use try/catch around code that can fail unpredictably — JSON parsing, network requests, file operations, or third-party library calls. Wrapping everything adds noise and hides bugs. As MDN recommends, handle errors at the appropriate level where you have enough context to recover meaningfully.
The ECMAScript specification defines seven: TypeError (wrong type), ReferenceError (undefined variable), SyntaxError (invalid syntax), RangeError (value out of range), URIError (bad URI encoding), EvalError (eval-related), and AggregateError (multiple errors, ES2021). TypeError and ReferenceError are by far the most common in practice.
Wrap await calls in try/catch blocks, just like synchronous code. Unhandled promise rejections can crash Node.js processes — since Node 15, unhandled rejections terminate the process by default. For multiple parallel promises, use Promise.allSettled() to capture both successes and failures without short-circuiting.
Create custom Error classes when you need to distinguish between error categories in catch blocks. For example, ValidationError, NotFoundError, and AuthenticationError let callers handle each case differently. Extend the built-in Error class and set a descriptive name property so stack traces remain informative.


Reference

Articles

Videos

Last modified on February 17, 2026