Asynchronous JavaScript in LWC · Lesson 15 of 25
async/await with Imperative Apex in LWC
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');
true the page keeps running meanwhile Acme: 2 contacts
awaitonly pauses theasyncfunction it's in; everything else keeps running.- A rejected promise makes
awaitthrow, so you catch it withtry/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>
⚠ 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();
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
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 cache | you 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
Write a
handleSavethat calls ApexsaveNotes({ caseId, notes }), shows a success toast, and always turns offisSaving.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; } }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.