LearnFrenzy
Sign in Take a free test

Asynchronous JavaScript in LWC · Lesson 15 of 25

async/await with Imperative Apex in LWC

6 min read Updated Nov 8, 2026 Saurabh Samir

async/await is promise code written to read top to bottom. For imperative Apex calls – the ones triggered by a button, a save, a search – it's now the standard style: easier to read, easier to debug, and error handling uses ordinary try/catch.

In this lesson

  • How async and await work
  • A complete load/save pattern with spinner and errors
  • Sequential vs parallel awaits
  • Ignoring stale responses
  • @wire or imperative?

How async/await works

const getAccount = (id) => new Promise((resolve) => setTimeout(() => resolve({ Id: id, Name: 'Acme' }), 20));
const getContacts = (accountId) => new Promise((resolve) => setTimeout(() => resolve(['Ravi', 'Neha']), 20));

async function loadPage(id) {
    const account = await getAccount(id);          // pauses this function, not the page
    const contacts = await getContacts(account.Id);
    return `${account.Name}: ${contacts.length} contacts`;   // an async function always returns a promise
}

const result = loadPage('001A');
console.log(result instanceof Promise);
result.then(console.log);
console.log('the page keeps running meanwhile');

Output

true
the page keeps running meanwhile
Acme: 2 contacts
  • await only pauses the async function it's in; everything else keeps running.
  • A rejected promise makes await throw, so you catch it with try/catch.
  • Compare the callback pyramid in the event loop lesson – same logic, flat code.

The load/save pattern

Almost every imperative call needs the same three pieces of state: loading, data and error. Here's the pattern, with a retry when it fails:

// caseList.js
import { LightningElement, api } from 'lwc';
import getCases from '@salesforce/apex/CaseController.getCases';

export default class CaseList extends LightningElement {
    @api accountId = '001A';
    cases = [];
    error;
    isLoading = false;

    connectedCallback() {
        this.load();
    }

    async load() {
        this.isLoading = true;
        this.error = undefined;
        try {
            this.cases = await getCases({ accountId: this.accountId });
        } catch (e) {
            this.cases = [];
            this.error = e.body?.message ?? 'Unknown error';
        } finally {
            this.isLoading = false;                  // runs on success and on failure
        }
    }

    handleRetry() {
        this.load();
    }

    get isEmpty() {
        return !this.isLoading && !this.error && this.cases.length === 0;
    }
}
<!-- caseList.html -->
<template>
    <template lwc:if={isLoading}><lightning-spinner alternative-text="Loading"></lightning-spinner></template>
    <template lwc:if={error}>
        <p>⚠ {error}</p>
        <lightning-button label="Retry" onclick={handleRetry}></lightning-button>
    </template>
    <template for:each={cases} for:item="c">
        <p key={c.Id}>{c.CaseNumber} – {c.Subject}</p>
    </template>
    <template lwc:if={isEmpty}><p>No open cases</p></template>
</template>

Result

⚠ Request timed out
[Retry]

› click “Retry”
00001027 – Login issue
00001031 – Invoice copy

Why reset error and data at the start

Clearing error before each attempt stops an old error message from showing alongside new results. Clearing data in catch stops stale results from looking current. Small details like this separate a production component from a demo.

Sequential vs parallel awaits

const call = (name, ms) => new Promise((resolve) => setTimeout(() => resolve(name), ms));

async function sequential() {
    const start = Date.now();
    const a = await call('cases', 100);          // waits…
    const b = await call('opportunities', 100);  // …then starts the second
    console.log(`sequential: ${a}, ${b} in ~${Math.round((Date.now() - start) / 100) * 100}ms`);
}

async function parallel() {
    const start = Date.now();
    const [a, b] = await Promise.all([call('cases', 100), call('opportunities', 100)]);   // both start now
    console.log(`parallel: ${a}, ${b} in ~${Math.round((Date.now() - start) / 100) * 100}ms`);
}

await sequential();
await parallel();

Output

sequential: cases, opportunities in ~200ms
parallel: cases, opportunities in ~100ms

Two awaits in a row means the second call doesn't start until the first finishes. Only do that when the second call needs the first result. Independent calls belong in Promise.all. (Top-level await, used on the last two lines, works in modules like this example – inside a component you await within methods.)

await inside forEach doesn't wait

records.forEach(async (r) => await save(r)) fires every save at once and the surrounding function carries on immediately. Use for (const r of records) await save(r); for one-by-one, or await Promise.all(records.map(save)) for parallel.

Ignoring stale responses

If the user changes a filter twice quickly, two requests are in flight. The first might return last and overwrite the newer results. Tag each request and keep only the latest:

let latestRequest = 0;
let shown = '';

const fakeSearch = (term, ms) => new Promise((resolve) => setTimeout(() => resolve(`results for ${term}`), ms));

async function search(term, ms) {
    const requestId = ++latestRequest;
    const result = await fakeSearch(term, ms);
    if (requestId !== latestRequest) {
        console.log(`dropped stale: ${result}`);
        return;
    }
    shown = result;
    console.log(`showing: ${shown}`);
}

search('Acm', 80);       // slow
search('Acme', 20);      // fast, and the newest

Output

showing: results for Acme
dropped stale: results for Acm

@wire or imperative?

Use @wire when…Use an imperative call when…
data should load automatically and follow reactive parameters ($recordId)the call happens in response to a user action
the Apex method is cacheable=true (read-only)the method changes data (DML) – it can't be cacheable
you want the Lightning Data Service cacheyou need control over timing, retries or the order of calls

After an imperative save changes data that a @wire shows, refresh the wire with refreshApex(this.wiredResult) (passing the whole provisioned value you stored), and for record data shown by LDS, call notifyRecordUpdateAvailable([{ recordId }]).

Practice

Try it yourself

  1. Write a handleSave that calls Apex saveNotes({ caseId, notes }), shows a success toast, and always turns off isSaving.

    Show answer

    async handleSave() { this.isSaving = true; try { await saveNotes({ caseId: this.recordId, notes: this.notes }); this.dispatchEvent(new ShowToastEvent({ title: 'Notes saved', variant: 'success' })); } catch (e) { this.error = e.body?.message ?? 'Save failed'; } finally { this.isSaving = false; } }

  2. A component awaits three independent Apex methods one after another and takes 3 seconds to load. What's the quick win?

    Show answer

    Start them together: const [a, b, c] = await Promise.all([m1(), m2(), m3()]); – about 1 second if each takes 1 second.

Quick check

What does an async function return?

Always a promise.

What happens when an awaited promise rejects?

await throws the error; catch it with try/catch.

Can a DML Apex method be called with @wire?

No – @wire needs cacheable=true; call DML methods imperatively.

Summary

  • async/await makes imperative Apex code read top to bottom.
  • Manage loading, data and error state; turn off spinners in finally.
  • Await independent calls together with Promise.all; never await inside forEach.
  • Drop stale responses, and refresh wired data after imperative changes.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular