Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

Short answer: an uncaught Apex exception reaches the browser as a generic internal server error with none of your message in it. To send the user something useful you must throw an AuraHandledException carrying a message you wrote, catch the rejection in JavaScript, pull the text out of error.body.message, and render it where the user is looking. Every step is easy to get wrong, and the usual result is a component that says “An internal server error has occurred” for six different underlying problems.

What actually crosses the wire

An imperative call returns a promise. The rejection value is not an Apex exception. It is a plain object the platform assembled from one.

{
    status: 500,
    body: { message: 'Credit limit exceeded for this account.',
            exceptionType: 'System.AuraHandledException',
            isUserDefinedException: true },
    headers: {},
    ok: false
}

Throw a DmlException, a NullPointerException, or let a null reference escape, and the client gets a message telling it an internal server error occurred, plus a reference id. Your text is not in there. That is deliberate, because uncaught server exceptions can carry information about internals that should not be shown, so the platform replaces the message.

AuraHandledException is the exception type the platform treats as safe, and it is the only one whose message is delivered as written.

Throwing properly

@AuraEnabled
public static void submitOrder(Id orderId) {
    try {
        Order__c order = [SELECT Id, Status__c, Total__c FROM Order__c
                          WHERE Id = :orderId WITH USER_MODE];
        if (order.Total__c > getCreditLimit(order)) {
            throw new OrderException('Order total exceeds the account credit limit.');
        }
        order.Status__c = 'Submitted';
        update as user order;
    } catch (OrderException e) {
        throw new AuraHandledException(e.getMessage());
    } catch (Exception e) {
        Logger.log(e);
        throw new AuraHandledException(
            'The order could not be submitted. Reference: ' + e.getMessage());
    }
}

Two rules here. Wrap your business rules in an exception type of your own so you can tell them apart from genuine failures, and never let a raw system exception through without logging it first. Once you convert to AuraHandledException, the stack trace is gone from the client, so if you did not log it server-side it is gone entirely.

Think about what a technical failure should say. The raw text of a DmlException hands the user a field name they have never heard of, though the validation rule message buried inside it is sometimes precisely what they need.

A quirk worth knowing in tests: constructing new AuraHandledException('text') and then calling getMessage() in Apex may return “Script-thrown exception” rather than your text. Calling setMessage() on the instance avoids the surprise if your test asserts on the message. The client still receives the text you supplied.

Catching in JavaScript

import submitOrder from '@salesforce/apex/OrderController.submitOrder';

async handleSubmit() {
    this.isSaving = true;
    this.errors = [];
    try {
        await submitOrder({ orderId: this.recordId });
        this.dispatchEvent(new ShowToastEvent({
            title: 'Order submitted', variant: 'success' }));
    } catch (error) {
        console.error(error);
        this.errors = reduceErrors(error);
    } finally {
        this.isSaving = false;
    }
}

The finally block matters more than it looks. A spinner that is only cleared in the success path stays on screen forever when the call fails, and users read that as a hung page.

Reading error.body.message directly works for a single AuraHandledException and breaks for everything else. A page-level error from the User Interface API gives you body as an array. A network failure gives you no body at all, only statusText. Use a reduceErrors helper that checks all three shapes, and keep one copy of it in a shared utility module.

Parameter names are the other silent failure. The object keys passed from JavaScript must match the Apex parameter names exactly, including case. A mismatch produces a rejection that reads like a server problem and is actually a typo.

Messages that are worth reading

  • Name the thing. “Order 00123 could not be submitted” beats “Submission failed”.
  • Say whether retrying helps. A credit limit will not fix itself. A timeout might.
  • Give a reference for anything unexpected, and log the same reference server-side.
  • Do not include the class name, the line number, or the word “exception”.

And keep the message on screen. An error that appears in a toast and vanishes after a few seconds has told the user nothing they can act on. Render it inline, near the control that triggered the call, with role="alert" so it is announced.

Frequently asked

Why is my custom message missing? The exception was not an AuraHandledException, so the platform replaced it with a generic one.

Should I return an error object instead of throwing? No. A thrown exception rolls back the transaction. A returned status code leaves partial writes committed.

How do I see the real stack trace? Log it in Apex before converting, or reproduce with a debug log open. It never reaches the browser.