LWC performance optimization has a dirty secret: most slow Lightning pages are not slow because of the framework. They are slow because of chatty data patterns, unguarded render-cycle work, and lists rendered without restraint. This guide covers the patterns with real payoff — data layer first, render cycle second, lists third — and how to measure before touching any of it, current to 2026.
For the framework-level context of why LWC starts from a faster baseline than Aura, see LWC vs Aura in 2026 — this guide assumes you’re on LWC and want to use that headroom well.
Measure first, or you’re guessing
Before any optimization: open Chrome DevTools → Performance, record the actual slow interaction, and read the trace. Thirty seconds of profiling routinely invalidates a week of planned “optimizations.” The two patterns that dominate real traces:
- Network waterfalls — sequential Apex calls where each waits for the previous, visible as a staircase in the Network panel
- Long render tasks — heavy synchronous work in getters, renderedCallback or event handlers, visible as wide blocks on the main thread
Everything below maps to one of those two.
The data layer — where most time lives
cacheable=true is the single highest-leverage line
public with sharing class ProductController {
@AuraEnabled(cacheable=true)
public static List<Product2> getActiveProducts(String family) {
return [SELECT Id, Name, Family FROM Product2
WHERE Family = :family AND IsActive = true];
}
}
Wired cacheable methods are served from the client-side cache on repeat invocations with the same parameters — no server round-trip at all. The constraint is the contract: cacheable methods are read-only; DML inside one throws. Split your controllers accordingly: cacheable reads, non-cacheable writes, and call refreshApex after a write to invalidate the stale read.
@wire by default, imperative by exception
@wire is reactive — change a reactive parameter and the data re-provisions automatically — and it participates in caching. Imperative calls run when you say so and skip the cache unless the method is cacheable. The working rule:
- Reads that drive the UI →
@wire - User-initiated mutations and one-shot reads (button clicks, form submits) → imperative
Lightning Data Service before Apex for single records
getRecord/getFieldValue read from a page-wide shared cache: if the record detail page already loaded the Account, your component’s getRecord resolves without touching the server, and any update — yours or another component’s — propagates to every subscriber. For single-record access with known fields, custom Apex is usually the slower, more expensive reimplementation of what LDS already does.
Kill the waterfall
Three wires on independent data run in parallel for free. The waterfall appears when developers chain imperative calls — await one, then the next, then the next. If call B doesn’t need call A’s result, fire them together:
const [products, pricing] = await Promise.all([
getActiveProducts({ family }),
getPricing({ family })
]);
The render cycle — the silent multiplier
Getters run on every render
Every getter referenced in the template re-evaluates on every render cycle. This getter looks innocent and is a per-render sort of the entire array:
get sortedRows() {
return [...this.rows].sort((a, b) => a.name.localeCompare(b.name));
}
Anything beyond trivial work should compute once, when the source changes — in the wire handler or setter — into a plain property the template reads.
renderedCallback fires after every render
The most common LWC infinite loop: renderedCallback mutates a reactive property → render → renderedCallback → render. One-time DOM work needs a guard:
renderedCallback() {
if (this.hasInitialized) return;
this.hasInitialized = true;
// one-time DOM measurement / third-party init
}
Debounce user input
A search box wired to Apex on every keystroke sends a request per character. Standard fix, ~300ms trailing debounce:
handleSearch(event) {
clearTimeout(this.debounceTimer);
const term = event.target.value;
this.debounceTimer = setTimeout(() => { this.searchTerm = term; }, 300);
}
With searchTerm as a reactive wire parameter, the wire then refires exactly once per pause in typing.
Lists — where pages go to die
- Always key your iterations.
for:eachwith a stablekeylets the diff reuse DOM nodes; without meaningful keys, every re-render rebuilds every row. - Cap what you mount. A few hundred rows is the practical ceiling for naive rendering. Beyond it: pagination, or
lightning-datatablewithenable-infinite-loadingto fetch and mount incrementally. - Mind per-row cost. A row containing five nested components costs five component instantiations × row count. Flatten heavy rows into markup; keep components for genuinely reusable interactive units.
The server side of large lists is its own discipline — query shape matters more than render shape at scale, covered in SOQL best practices for large data volumes.
Loading discipline
- Lazy-load below-the-fold and conditional UI. A modal’s contents behind
lwc:ifaren’t instantiated until opened — free win, often forgotten. - Static resources load via
loadScript/loadStylein a guardedrenderedCallbackor on first use — not eagerly inconnectedCallbackfor a library only one tab needs. - Images: native
loading="lazy"on anything below the fold.
The priority order
When a Lightning page is slow, work this list top-down and stop when it’s fast enough:
- Profile the interaction (30 seconds, non-negotiable)
- Make reads cacheable and wired; verify repeat navigation hits cache
- Break call waterfalls with
Promise.all - Move heavy getter work to compute-on-change
- Guard
renderedCallback; debounce inputs - Key, paginate or virtualize every large list
In practice, items 2 and 3 fix the majority of real-world complaints — the framework was never the problem.
Test your knowledge — LWC & Aura
10 questions · Basic to Advanced