Best Practices11 min read · Updated 2025-06-01

JavaScript Error Handling: A Complete Guide to Errors, try/catch, and Async Failures

Most JavaScript error handling is defensive theater — try/catch blocks that swallow errors and log nothing useful. Here's how to build a system that actually tells you what went wrong and where.

💡 Practice these concepts interactively with AI feedback

Start Practicing →

Bad error handling is invisible until it costs you. A swallowed exception that corrupts data silently. A failed API call that returns undefined and causes a crash three function calls later. A try/catch that catches an error, logs nothing useful, and lets the user stare at a blank screen.

Good error handling is a system — not a reflex to wrap things in try/catch. This guide builds that system from the ground up.

JavaScript's Built-In Error Types

JavaScript ships with seven error constructors. Each represents a specific category of failure:

Error — the base class. Use it for application-level errors that don't fit a more specific type.

TypeError — wrong type. Accessing a property on null or undefined, calling a non-function, passing the wrong type to a function that requires a specific one.

ReferenceError — variable doesn't exist. Accessing an undeclared variable, using a let/const in its TDZ.

SyntaxError — invalid JavaScript. Thrown by the parser — you can't catch it in the file that contains it (the file never executes). Can be caught from eval() or JSON.parse().

RangeError — value out of range. new Array(-1), Number.toFixed(200), infinite recursion.

URIError — malformed URI in decodeURIComponent().

EvalError — legacy, rarely thrown in modern JS.

null.property           // TypeError: Cannot read properties of null
undeclaredVar           // ReferenceError: undeclaredVar is not defined
JSON.parse('{bad}')     // SyntaxError: Unexpected token b in JSON
new Array(-1)           // RangeError: Invalid array length

Every error object has:

Custom Error Classes — The Right Way

Throwing a plain Error with a message is a start. But in a real application you need to handle errors differently based on their type — retry on network failure, redirect on auth failure, show a specific message on validation failure. Custom error classes make this possible:

class AppError extends Error {
  constructor(message, options = {}) {
    super(message)
    this.name    = this.constructor.name  // 'NetworkError', 'ValidationError', etc.
    this.code    = options.code           // machine-readable error code
    this.context = options.context        // relevant data for debugging
    this.isOperational = options.isOperational ?? true  // expected vs programmer error

// Maintains proper stack trace in V8 (Node.js, Chrome) if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor) } } }

class NetworkError extends AppError { constructor(message, statusCode, options = {}) { super(message, { code: HTTP_${statusCode}, ...options }) this.statusCode = statusCode } }

class ValidationError extends AppError { constructor(message, field, options = {}) { super(message, { code: 'VALIDATION_FAILED', ...options }) this.field = field } }

class AuthError extends AppError { constructor(message, options = {}) { super(message, { code: 'UNAUTHORIZED', ...options }) this.statusCode = 401 } }

Now error handling becomes decision-making instead of blind catching:

async function loadUserProfile(userId) {
  try {
    const user = await fetchUser(userId)
    return user
  } catch (err) {
    if (err instanceof AuthError) {
      redirect('/login')
    } else if (err instanceof NetworkError && err.statusCode >= 500) {
      scheduleRetry()
    } else if (err instanceof ValidationError) {
      showFieldError(err.field, err.message)
    } else {
      // Unexpected error — surface it, don't swallow it
      errorReporter.capture(err)
      throw err
    }
  }
}

try/catch — What It Does and Doesn't Catch

try/catch catches synchronous errors and rejected Promises when used with await:

// ✓ Catches synchronous errors
try {
  const data = JSON.parse(invalidJson)
} catch (err) {
  console.error('Parse failed:', err.message)
}

// ✓ Catches async errors with await try { const user = await fetchUser(id) } catch (err) { console.error('Fetch failed:', err.message) }

// ❌ Does NOT catch asynchronous errors without await try { setTimeout(() => { throw new Error('this escapes try/catch') }, 100) } catch (err) { // Never runs — the throw happens after try/catch has already finished }

// ❌ Does NOT catch unhandled Promise rejections try { fetchUser(id) // no await — rejection is unhandled } catch (err) { // Never runs }

The finally block: runs whether the try block succeeded or threw. Critical for cleanup — closing connections, hiding loading states, releasing locks:

async function withTransaction(db, operations) {
  const transaction = await db.beginTransaction()
  try {
    const result = await operations(transaction)
    await transaction.commit()
    return result
  } catch (err) {
    await transaction.rollback()
    throw err  // re-throw — caller needs to know it failed
  } finally {
    transaction.release()  // always release the connection
  }
}

The catch variable is scoped to the catch block — you can't use it after:

try {
  riskyOperation()
} catch (err) {
  logged = true
}
console.log(err)  // ReferenceError — err doesn't exist here

The Two Biggest Mistakes

Mistake 1: Swallowing errors

// ❌ The worst pattern in production code
try {
  await doSomething()
} catch (err) {
  // Completely silent — the error happened, nobody will ever know
}

// ❌ Almost as bad — logs but doesn't re-throw or handle try { await doSomething() } catch (err) { console.log(err) // logs and moves on as if nothing happened }

If you catch an error and don't handle it meaningfully, re-throw it. Never let errors disappear silently.

Mistake 2: Catching errors you can't handle

Catch errors at the level where you can do something about them. Don't wrap every function in try/catch "to be safe" — that just spreads the handling logic everywhere and makes the actual problem invisible.

// ❌ Wrapping everything — error is lost at the lowest level
async function getUserName(id) {
  try {
    const user = await fetchUser(id)
    return user.name
  } catch (err) {
    return null  // caller has no idea why name is null
  }
}

// ✓ Let errors propagate to where they can be meaningfully handled async function getUserName(id) { const user = await fetchUser(id) // let this throw if it fails return user.name }

// Handle at the component or route level where you control the UI async function UserPage({ id }) { try { const name = await getUserName(id) return render(name) } catch (err) { if (err instanceof NetworkError) return renderError('Failed to load user') throw err // unexpected — escalate } }

Async Error Handling Patterns

Promise chains:

fetchUser(id)
  .then(user => processUser(user))
  .then(result => saveResult(result))
  .catch(err => {
    // Catches ANY rejection from any step in the chain above
    handleError(err)
  })

async/await with error boundaries:

// Wrap risky operations together when a single error should abort the whole thing
async function processOrder(orderId) {
  try {
    const order   = await fetchOrder(orderId)
    const payment = await chargePayment(order.total)
    const receipt = await sendReceipt(order, payment)
    return { order, payment, receipt }
  } catch (err) {
    await cancelOrder(orderId)
    throw new OrderError(Order ${orderId} failed: ${err.message}, { cause: err })
  }
}

// Independent operations — handle failures separately async function loadDashboard(userId) { const [analyticsResult, notificationsResult] = await Promise.allSettled([ fetchAnalytics(userId), fetchNotifications(userId), ])

return { analytics: analyticsResult.status === 'fulfilled' ? analyticsResult.value : null, notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : [], } }

Error cause (ES2022) — chain errors with context:

try {
  await db.query(sql)
} catch (err) {
  throw new DatabaseError('Query failed', {
    cause: err,    // original error preserved
    context: { sql }
  })
}

// Access the original error: try { await runOperation() } catch (err) { console.log(err.cause) // the original underlying error }

Global Error Handling — The Safety Net

Unhandled errors and rejections that escape local catch blocks need a global handler. Never let them disappear silently:

// Browser — unhandled synchronous errors
window.addEventListener('error', (event) => {
  errorReporter.capture({
    message: event.message,
    source:  event.filename,
    line:    event.lineno,
    error:   event.error,
  })
  // Return true to prevent the default browser error console
})

// Browser — unhandled Promise rejections window.addEventListener('unhandledrejection', (event) => { errorReporter.capture(event.reason) event.preventDefault() // prevents console warning })

// Node.js process.on('uncaughtException', (err) => { logger.fatal({ err }, 'Uncaught exception — shutting down') process.exit(1) // always exit — process is in unknown state })

process.on('unhandledRejection', (reason, promise) => { logger.error({ reason }, 'Unhandled rejection') // In Node 15+, this also causes process exit })

Designing for Observability

A caught error is only valuable if you can understand what happened from the outside. Every error you handle should leave a trace:

class ErrorReporter {
  static capture(err, context = {}) {
    const report = {
      message:   err.message,
      name:      err.name,
      stack:     err.stack,
      code:      err.code,
      context:   { ...err.context, ...context },
      timestamp: new Date().toISOString(),
      userId:    getCurrentUserId(),
      sessionId: getSessionId(),
    }

// In development: console. In production: Sentry, Datadog, etc. if (process.env.NODE_ENV === 'production') { Sentry.captureException(err, { extra: report }) } else { console.group(🚨 ${err.name}: ${err.message}) console.error(report) console.groupEnd() } } }

The goal: when an error alert wakes you up at 2am, the logged error should contain everything you need to reproduce and fix it without asking the user anything.

The Error Handling Checklist

📚 Practice These Topics
Classes
5–8 questions
Prototype
6–10 questions
Async await
5–8 questions
Error handling
5–8 questions

Put This Into Practice

Reading articles is passive. JSPrep Pro makes you actively recall, predict output, and get AI feedback.

Start Free →Browse All Questions

Related Articles

Core Concepts
map() vs forEach() in JavaScript: Which One to Use and Why It Matters
7 min read
Core Concepts
Arrow Functions vs Regular Functions in JavaScript: 6 Key Differences
9 min read
Interview Prep
Promise.all vs allSettled vs race vs any: The Complete Comparison
9 min read