LWC Performance Patterns That Actually Move the Needle
A record-page dashboard that's snappy with test data and crawls in production usually has the same handful of problems. Here are the patterns that fix them.
Lightning Web Components are fast by default — until you put real data behind them. The slowdowns are predictable, and so are the fixes. None of these are exotic; they're the difference between a dashboard that loads in 300ms and one that hangs the record page.
1. Prefer @wire for cacheable reads
The Lightning Data Service cache is free performance you're often leaving on the table. For read-only data, a @wire to a cacheable=true Apex method (or better, a UI API wire adapter) serves repeat requests from cache and refreshes reactively when the cached record changes.
import getEngagements from '@salesforce/apex/EngagementController.getRecent';
export default class EngagementDashboard extends LightningElement {
@api recordId;
@wire(getEngagements, { contactId: '$recordId' })
engagements; // cached, reactive to recordId changes
}
// Apex must be cacheable for the wire cache to engage
@AuraEnabled(cacheable=true)
public static List<Engagement__c> getRecent(Id contactId) { ... }
Reach for imperative Apex only when you need to call on demand (a button click) or when the call mutates data. Default to @wire for reads.
2. Don't render what nobody's looking at
Tabs, accordions, and modals that build their whole DOM on page load are a silent tax. Gate them with lwc:if so the subtree only mounts when shown.
<template lwc:if={showDetails}>
<c-expensive-detail-panel record-id={recordId}></c-expensive-detail-panel>
</template>
lwc:if actually removes the element from the DOM (and tears down its component), unlike CSS display:none, which keeps it alive and reactive.
3. Getters run on every render — keep them cheap
A getter referenced in the template is re-evaluated on every re-render. If it sorts or filters a large array, you're paying that cost repeatedly. Compute once when the data arrives and cache the result.
// ❌ re-sorts on every render
get sortedItems() {
return [...this.items].sort((a, b) => a.name.localeCompare(b.name));
}
// ✅ compute once when data lands
@wire(getItems)
wiredItems({ data }) {
if (data) {
this._sorted = [...data].sort((a, b) => a.name.localeCompare(b.name));
}
}
get sortedItems() { return this._sorted; }
4. Key your iterations correctly
A stable, unique key on for:each lets the engine diff efficiently instead of re-rendering the whole list. Use the record Id — never the array index, which defeats the diff the moment the list reorders.
<template for:each={rows} for:item="row">
<c-row key={row.Id} record={row}></c-row>
</template>
5. Query narrow, paginate early
The component is only as fast as the Apex behind it. Select the fields you render and nothing more, and paginate at the query (LIMIT / OFFSET or keyset) rather than pulling thousands of rows and slicing client-side. The fastest DOM is the one you never built because the data never arrived.
6. Debounce user-driven calls
A search-as-you-type box that hits Apex on every keystroke will flood the server and the UI. Debounce it.
handleSearch(event) {
window.clearTimeout(this._t);
const term = event.target.value;
this._t = setTimeout(() => { this.runSearch(term); }, 300);
}
The short version
- Default to
@wire+cacheable=truefor reads. - Gate expensive subtrees with
lwc:if. - Keep template getters trivial; precompute on data arrival.
- Key iterations by record Id.
- Move the work to the query: narrow fields, server-side pagination.
- Debounce anything driven by typing.
Six patterns. They're what separate a dashboard that demos well from one that survives a power user with 2,000 related records.