Part 85: LWC Exception Handling
If you have been writing LWC code through the earlier parts of this series, you have already encountered errors. Maybe an Apex call returned something unexpected. Maybe a wire adapter gave you undefined data. Maybe you tried to access a DOM element that had not rendered yet. Up until now, you probably handled those situations by checking for null values or adding console.log statements and hoping for the best.
That approach stops working the moment your components get real users. In production, things go wrong constantly — network calls fail, users enter bad data, Apex methods throw exceptions, and third-party integrations time out. If your component does not handle these failures gracefully, users see blank screens, cryptic browser errors, or worse, they see nothing at all and assume their action succeeded when it did not.
Exception handling is how you deal with all of that. We covered JavaScript exceptions back in Part 76, so you already understand the language-level mechanics. And in Part 84, we worked through calling Apex from LWC, where you saw that server-side errors need to be caught and handled on the client. This post brings everything together in the LWC context. We will cover how to catch errors, how to throw meaningful ones, how to display them to users with toast notifications, and how to build a reusable pattern that keeps your error handling consistent across an entire application.
This post covers:
- What is Exception Handling — Why it matters in LWC specifically.
- Try-Catch Blocks in JS — Quick refresher applied to LWC methods.
- Throwing Errors in JS — When and how to throw your own errors.
- Creating Custom Errors in JS — Building error classes that carry structured data.
- How to Create Toasts to Display to Users — Using the platform toast event.
- How to Build a Reusable Exception Handling Module — A shared utility for consistent error handling.
- Catching Promise Failures — Handling async and promise-based errors.
- How to Handle Apex Exceptions — Dealing with server-side errors from Apex.
- Section Notes — Key takeaways and patterns to remember.
Let’s get into it.
What Is Exception Handling?
Exception handling is the practice of anticipating, detecting, and responding to errors that occur while your code is running. In JavaScript, an exception is an object that gets created when something goes wrong — a variable is undefined, a network request fails, a function receives invalid input. If you do not handle that exception, it bubbles up through the call stack and eventually crashes your component or produces an unhandled promise rejection warning in the console.
In the context of LWC, exception handling matters more than in plain JavaScript for a few reasons. First, your components live inside the Salesforce platform, which means errors can come from many sources: your own JavaScript logic, Apex controllers, wire adapters, Lightning Data Service, navigation service, or the platform event system. Second, your users are business users who need clear feedback about what went wrong and what they should do next. A console error is meaningless to someone trying to update an opportunity record. Third, LWC components often exist alongside other components on the same page. An unhandled error in one component can break the user’s experience of the entire page.
Good exception handling means three things: catching errors before they crash the component, giving the user a clear message about what happened, and logging enough detail for developers to diagnose the problem later.
Try-Catch Blocks in JS
The fundamental tool for exception handling is the try-catch block. You wrap code that might fail inside the try block, and if an error occurs, execution jumps to the catch block where you can handle it.
Here is a basic example inside an LWC method:
import { LightningElement } from 'lwc';
export default class AccountProcessor extends LightningElement {
processData() {
try {
const data = JSON.parse(this.rawInput);
this.accountName = data.Name;
this.accountIndustry = data.Industry;
} catch (error) {
console.error('Failed to parse account data:', error.message);
this.accountName = 'Unknown';
this.accountIndustry = 'Unknown';
}
}
}
If this.rawInput is not valid JSON, JSON.parse throws a SyntaxError. Without the try-catch, that error would propagate up and potentially break the component. With it, you catch the problem, log a useful message, and set sensible default values so the component can continue functioning.
You can also use the finally block, which runs regardless of whether an error occurred:
processRecords() {
this.isLoading = true;
try {
this.results = this.transformRecords(this.rawRecords);
} catch (error) {
console.error('Record transformation failed:', error.message);
this.results = [];
} finally {
this.isLoading = false;
}
}
The finally block is particularly useful in LWC for resetting loading spinners, re-enabling buttons, or cleaning up temporary state. No matter what happens in the try or catch blocks, the finally block guarantees your UI state gets cleaned up.
One important thing to remember: try-catch only works for synchronous code. If you have an asynchronous operation inside the try block, the catch will not capture errors from it unless you use async/await. We will cover that in the promise failures section.
Throwing Errors in JS
Sometimes the right thing to do is not catch an error, but throw one. When your function receives input that violates its expectations, you should throw an error early rather than letting bad data flow through your logic and cause confusing failures later.
validateOpportunityAmount(amount) {
if (amount === null || amount === undefined) {
throw new Error('Opportunity amount is required');
}
if (typeof amount !== 'number') {
throw new TypeError('Opportunity amount must be a number');
}
if (amount < 0) {
throw new RangeError('Opportunity amount cannot be negative');
}
return true;
}
Notice that JavaScript has several built-in error types: Error for general problems, TypeError for type-related issues, and RangeError for values outside acceptable limits. Using the appropriate type makes your errors more descriptive and easier to handle in catch blocks.
In LWC, you will commonly throw errors in utility functions, data transformation methods, and validation logic. The caller of these functions should wrap them in try-catch:
handleSave() {
try {
this.validateOpportunityAmount(this.amount);
// proceed with save
} catch (error) {
if (error instanceof RangeError) {
this.showToast('Validation Error', error.message, 'warning');
} else {
this.showToast('Error', 'An unexpected error occurred', 'error');
}
}
}
Using instanceof to check the error type lets you respond differently to different kinds of failures. A validation error might warrant a warning toast, while an unexpected error might need a more alarming notification and a log entry.
Creating Custom Errors in JS
The built-in error types are useful, but they do not carry structured information. When you are building a real application, you often need errors that include an error code, a user-friendly message separate from the technical message, or metadata about what went wrong. Custom error classes solve this.
// customErrors.js
export class ApexCallError extends Error {
constructor(message, apexMethod, statusCode) {
super(message);
this.name = 'ApexCallError';
this.apexMethod = apexMethod;
this.statusCode = statusCode;
this.timestamp = new Date().toISOString();
}
}
export class ValidationError extends Error {
constructor(message, fieldName, invalidValue) {
super(message);
this.name = 'ValidationError';
this.fieldName = fieldName;
this.invalidValue = invalidValue;
}
}
export class PermissionError extends Error {
constructor(message, requiredPermission) {
super(message);
this.name = 'PermissionError';
this.requiredPermission = requiredPermission;
}
}
Now you can throw errors that carry meaningful context:
import { ValidationError } from 'c/customErrors';
validateEmail(email) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
throw new ValidationError(
'Invalid email format',
'ContactEmail',
email
);
}
}
And catch them with full context available:
try {
this.validateEmail(this.contactEmail);
} catch (error) {
if (error instanceof ValidationError) {
console.error(
`Validation failed on field ${error.fieldName}: ` +
`value "${error.invalidValue}" - ${error.message}`
);
this.fieldErrors[error.fieldName] = error.message;
}
}
Custom errors become especially valuable when you are building a large application with multiple developers. Instead of passing around plain strings or generic Error objects, you create a shared vocabulary of error types that everyone on the team understands and can handle consistently.
How to Create Toasts to Display to Users
When something goes wrong in an LWC component, you need to tell the user. The Salesforce platform provides toast notifications for exactly this purpose. Toasts are the small banners that appear at the top of the page with a message and automatically dismiss after a few seconds.
To show a toast, you dispatch a ShowToastEvent:
import { LightningElement } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
export default class ContactForm extends LightningElement {
handleSaveSuccess() {
this.dispatchEvent(
new ShowToastEvent({
title: 'Success',
message: 'Contact record saved successfully',
variant: 'success'
})
);
}
handleSaveError(error) {
this.dispatchEvent(
new ShowToastEvent({
title: 'Error Saving Contact',
message: error.message || 'An unexpected error occurred',
variant: 'error',
mode: 'sticky'
})
);
}
}
The ShowToastEvent accepts several properties. The title is the bold heading of the toast. The message is the body text. The variant controls the color and icon — it can be info, success, warning, or error. The mode controls how the toast dismisses: dismissible (the default) adds a close button and auto-dismisses, pester auto-dismisses without a close button, and sticky stays visible until the user explicitly closes it. For error messages, sticky mode is usually the right choice because you want to make sure the user actually sees the error.
You can also include links in toast messages using the messageData property:
this.dispatchEvent(
new ShowToastEvent({
title: 'Record Created',
message: 'View the new {0}',
messageData: [
{
url: `/${recordId}`,
label: 'contact record'
}
],
variant: 'success'
})
);
One thing to keep in mind: toast notifications only work when your component runs inside a Lightning Experience page or a Lightning app. They will not work inside a standalone Aura app or certain community page configurations. If your component needs to work in environments where toasts are not supported, you should have a fallback mechanism, like rendering an inline error message in your template.
How to Build a Reusable Exception Handling Module
Once you have more than a handful of components, you will notice that your error handling code starts looking the same everywhere — catch the error, figure out what type it is, extract a user-friendly message, show a toast, log the details. Instead of duplicating that logic across every component, build a shared module.
Here is a pattern that works well:
// errorHandler.js
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
const DEFAULT_ERROR_TITLE = 'Error';
const DEFAULT_ERROR_MESSAGE = 'An unexpected error occurred. Please try again.';
/**
* Extracts a user-friendly message from various error shapes.
*/
export function reduceErrors(errors) {
if (!Array.isArray(errors)) {
errors = [errors];
}
return errors
.filter((error) => !!error)
.map((error) => {
// Apex error shape
if (error.body) {
if (Array.isArray(error.body)) {
return error.body
.map((e) => e.message)
.join(', ');
}
if (error.body.message) {
return error.body.message;
}
if (error.body.output &&
error.body.output.fieldErrors) {
const fieldErrors = error.body.output.fieldErrors;
const messages = [];
Object.keys(fieldErrors).forEach((field) => {
fieldErrors[field].forEach((fe) => {
messages.push(`${field}: ${fe.message}`);
});
});
return messages.join(', ');
}
}
// Standard JS error
if (error.message) {
return error.message;
}
// String error
if (typeof error === 'string') {
return error;
}
return DEFAULT_ERROR_MESSAGE;
})
.join('; ');
}
/**
* Shows an error toast on the given component.
*/
export function handleError(component, error, title) {
const message = reduceErrors(error);
component.dispatchEvent(
new ShowToastEvent({
title: title || DEFAULT_ERROR_TITLE,
message: message,
variant: 'error',
mode: 'sticky'
})
);
console.error(`[${title || DEFAULT_ERROR_TITLE}]`, error);
}
/**
* Shows a success toast on the given component.
*/
export function showSuccess(component, message, title) {
component.dispatchEvent(
new ShowToastEvent({
title: title || 'Success',
message: message,
variant: 'success'
})
);
}
/**
* Shows a warning toast on the given component.
*/
export function showWarning(component, message, title) {
component.dispatchEvent(
new ShowToastEvent({
title: title || 'Warning',
message: message,
variant: 'warning'
})
);
}
The key function here is reduceErrors. Salesforce errors come in many different shapes depending on the source — Apex errors have a body property, wire adapter errors might have nested field errors, and regular JavaScript errors just have a message string. This function normalizes all of those into a single string that you can display to the user.
Now in any component, you just import the module and use it:
import { LightningElement } from 'lwc';
import { handleError, showSuccess } from 'c/errorHandler';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
export default class AccountList extends LightningElement {
accounts = [];
async connectedCallback() {
try {
this.accounts = await getAccounts();
showSuccess(this, 'Accounts loaded successfully');
} catch (error) {
handleError(this, error, 'Failed to Load Accounts');
this.accounts = [];
}
}
}
Every component in your project can use the same two or three functions for all their error handling. The toast formatting, error message extraction, and console logging all happen in one place. If you later decide to add server-side logging or analytics tracking for errors, you change it in one module and every component benefits.
Catching Promise Failures
Asynchronous code is where most unhandled errors hide in LWC. If you call an Apex method or use fetch without properly handling the rejection, you get an unhandled promise rejection — which in many browsers just logs a warning and silently fails.
There are two ways to handle promise failures: the .then().catch() pattern and async/await with try-catch.
Here is the .then().catch() approach:
import getContacts from '@salesforce/apex/ContactController.getContacts';
loadContacts() {
getContacts({ accountId: this.accountId })
.then((result) => {
this.contacts = result;
})
.catch((error) => {
handleError(this, error, 'Failed to Load Contacts');
this.contacts = [];
})
.finally(() => {
this.isLoading = false;
});
}
And here is the same thing with async/await:
async loadContacts() {
this.isLoading = true;
try {
this.contacts = await getContacts({
accountId: this.accountId
});
} catch (error) {
handleError(this, error, 'Failed to Load Contacts');
this.contacts = [];
} finally {
this.isLoading = false;
}
}
Both approaches work. The async/await version tends to be more readable, especially when you have multiple sequential async operations that depend on each other:
async createAndLinkRecords() {
this.isLoading = true;
try {
const accountId = await createAccount({
name: this.accountName
});
const contactId = await createContact({
accountId: accountId,
lastName: this.contactLastName
});
await linkToOpportunity({
contactId: contactId,
opportunityId: this.opportunityId
});
showSuccess(this, 'Records created and linked successfully');
} catch (error) {
handleError(this, error, 'Record Creation Failed');
} finally {
this.isLoading = false;
}
}
If any of the three Apex calls fails, the catch block handles it immediately and the remaining calls are skipped. With the .then() chain, achieving this same behavior requires nesting or returning promises in a more complex way.
One subtle issue to watch for: if you have multiple independent promises that should run in parallel, use Promise.allSettled instead of Promise.all. The difference is that Promise.all rejects as soon as any single promise fails, while Promise.allSettled waits for all of them to complete and tells you which ones succeeded and which ones failed:
async loadDashboardData() {
const results = await Promise.allSettled([
getAccountSummary({ accountId: this.recordId }),
getRecentActivities({ accountId: this.recordId }),
getOpenCases({ accountId: this.recordId })
]);
results.forEach((result, index) => {
const labels = ['Account Summary', 'Activities', 'Cases'];
if (result.status === 'fulfilled') {
this.dashboardData[index] = result.value;
} else {
console.error(`${labels[index]} failed:`, result.reason);
this.dashboardData[index] = null;
}
});
}
This pattern is ideal for dashboard-style components where partial data is better than no data. If the cases call fails but accounts and activities succeed, you still show two out of three sections instead of showing nothing.
How to Handle Apex Exceptions
Apex exceptions are the most common type of error you will deal with in LWC, and they have their own structure that you need to understand. When an Apex method throws an exception, the LWC framework wraps it in an error object with a specific shape.
On the Apex side, you should use AuraHandledException for errors that you want to communicate back to the client with a user-friendly message:
@AuraEnabled
public static List<Account> getAccounts(String industry) {
try {
if (String.isBlank(industry)) {
throw new AuraHandledException('Industry filter is required');
}
return [
SELECT Id, Name, Industry
FROM Account
WHERE Industry = :industry
WITH SECURITY_ENFORCED
];
} catch (QueryException e) {
throw new AuraHandledException(
'You do not have permission to view these records'
);
} catch (Exception e) {
throw new AuraHandledException(
'An error occurred while loading accounts: ' + e.getMessage()
);
}
}
The key detail here is that AuraHandledException is the only exception type that passes its message directly to the LWC client. If you throw a plain DMLException or QueryException, the client receives a generic error message instead of the specific text you wrote. Always wrap your Apex errors in AuraHandledException when you want the LWC to display a meaningful message.
On the LWC side, the error object from a failed Apex call looks like this:
{
body: {
message: "Industry filter is required"
},
ok: false,
status: 400,
statusText: "Bad Request"
}
This is why the reduceErrors function we built earlier checks for error.body.message — that is where the Apex exception message lives.
For wire adapters, the error handling is slightly different. When you use @wire to call an Apex method, errors come through the error parameter of the wired function:
import { LightningElement, wire } from 'lwc';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
import { reduceErrors } from 'c/errorHandler';
export default class AccountList extends LightningElement {
accounts = [];
errorMessage;
@wire(getAccounts, { industry: '$selectedIndustry' })
wiredAccounts({ error, data }) {
if (data) {
this.accounts = data;
this.errorMessage = undefined;
} else if (error) {
this.errorMessage = reduceErrors(error);
this.accounts = [];
}
}
}
With wired methods, you cannot show a toast in the wire handler itself because the component might not be fully rendered yet when the wire fires. Instead, set an error property and display it in your template:
<template>
<template if:true={errorMessage}>
<div class="slds-notify slds-notify_alert slds-alert_error">
<p>{errorMessage}</p>
</div>
</template>
<template if:true={accounts}>
<!-- render accounts -->
</template>
</template>
One more pattern worth knowing: sometimes you need to handle different Apex error types differently on the client. You can include error codes or structured data in your Apex exception message using JSON:
throw new AuraHandledException(
JSON.serialize(new Map<String, Object>{
'code' => 'INSUFFICIENT_ACCESS',
'message' => 'You do not have access to this record',
'field' => 'OwnerId'
})
);
Then on the LWC side, parse the message to get structured error details:
catch (error) {
try {
const errorData = JSON.parse(error.body.message);
if (errorData.code === 'INSUFFICIENT_ACCESS') {
this.showPermissionError(errorData.field);
} else {
handleError(this, errorData.message);
}
} catch (parseError) {
handleError(this, error, 'Server Error');
}
}
This pattern gives you the best of both worlds — structured error data that you can handle programmatically, delivered through the standard Apex exception mechanism.
Section Notes
Exception handling is one of those topics that separates production-quality code from demo code. Everything works when the inputs are perfect, the network is reliable, and the user does what you expect. Exception handling is what keeps your application working when none of those conditions hold.
Here are the key patterns to take away from this section:
Always use try-catch around code that can fail. This includes JSON parsing, DOM queries, data transformation, and any computation that depends on external input. Do not assume your data is clean.
Use async/await with try-catch for promise-based code. It reads like synchronous code and handles errors in the same familiar way. Use Promise.allSettled when you have independent parallel operations and want partial results instead of all-or-nothing.
Throw errors early with meaningful messages. Validation functions should throw specific errors with clear descriptions. Use custom error classes when you need to carry structured data like field names, error codes, or timestamps.
Build a shared error handling module. The reduceErrors function and the handleError wrapper should exist in every LWC project. They normalize the dozen different error shapes Salesforce can throw and give you a single place to control logging, toast formatting, and error reporting.
Use AuraHandledException on the Apex side. It is the only exception type that preserves your custom message for the LWC client. Wrap all other exception types in it before they leave your Apex controller.
Use toasts for user-facing errors, inline messages for wire errors. Toast notifications work well for imperative operations where the user clicked a button. For wire adapter errors, render the error message directly in your template since the component might not be ready for toasts when the wire fires.
Always clean up loading state. Use the finally block to reset loading spinners and re-enable buttons. A stuck spinner is worse than an error message because the user has no way to recover.
In the next section, we will continue building on these patterns as we look at more advanced LWC topics. The error handling module we built here will be something you use in nearly every component going forward. See you there.