Pro Patterns for LWC JavaScript · Lesson 23 of 25
LWC JavaScript Best Practices and Performance
Most slow components aren't slow because JavaScript is slow. They're slow because they do the same work over and over: getters that re-sort thousands of rows on every render, Apex calls that could be cached, sections rendered that nobody opened. This lesson collects the habits that keep components fast and code that a teammate can pick up in a year.
In this lesson
- How often getters really run
- Render less, load less
- Server calls: cache, batch, debounce
- Cleanup and memory
- Conventions and lint rules
Getters run on every render
A getter used in the template runs each time the component re-renders – for any reason, not just when its own data changes. Let's count:
// leaderboard.js
import { LightningElement } from 'lwc';
export default class Leaderboard extends LightningElement {
reps = [
{ id: 1, name: 'Asha', won: 12 },
{ id: 2, name: 'Ravi', won: 19 },
{ id: 3, name: 'Neha', won: 15 },
];
showHelp = false;
get ranked() {
console.log('sorting reps…'); // imagine 5,000 rows here
return this.reps.toSorted((a, b) => b.won - a.won);
}
toggleHelp() {
this.showHelp = !this.showHelp; // unrelated to the ranking
}
}
<!-- leaderboard.html -->
<template>
<lightning-button label="Help" onclick={toggleHelp}></lightning-button>
<template lwc:if={showHelp}><p>Ranked by deals won this quarter.</p></template>
<template for:each={ranked} for:item="rep">
<p key={rep.id}>{rep.name} – {rep.won}</p>
</template>
</template>
[Help] Ravi – 19 Neha – 15 Asha – 12 console: sorting reps… › click “Help” [Help] Ranked by deals won this quarter. Ravi – 19 Neha – 15 Asha – 12 console: sorting reps… › click “Help” [Help] Ravi – 19 Neha – 15 Asha – 12 console: sorting reps…
Clicking an unrelated button re-sorted the list twice. For three rows that doesn't matter; for thousands, it's visible lag. The fix: compute when the inputs change and store the result in a field.
// leaderboardFast.js
import { LightningElement } from 'lwc';
export default class LeaderboardFast extends LightningElement {
ranked = [];
showHelp = false;
connectedCallback() {
this.setReps([
{ id: 1, name: 'Asha', won: 12 },
{ id: 2, name: 'Ravi', won: 19 },
{ id: 3, name: 'Neha', won: 15 },
]);
}
setReps(reps) { // call this whenever the data changes (wire handler, refresh)
console.log('sorting reps…');
this.ranked = reps.toSorted((a, b) => b.won - a.won);
}
toggleHelp() {
this.showHelp = !this.showHelp;
}
}
<!-- leaderboardFast.html -->
<template>
<lightning-button label="Help" onclick={toggleHelp}></lightning-button>
<template lwc:if={showHelp}><p>Ranked by deals won this quarter.</p></template>
<template for:each={ranked} for:item="rep">
<p key={rep.id}>{rep.name} – {rep.won}</p>
</template>
</template>
[Help] Ravi – 19 Neha – 15 Asha – 12 console: sorting reps… › click “Help” [Help] Ranked by deals won this quarter. Ravi – 19 Neha – 15 Asha – 12 › click “Help” [Help] Ravi – 19 Neha – 15 Asha – 12
Cheap getters (a label, a boolean, a CSS class) are perfectly fine – that's what they're for. Move only the expensive work: sorting, filtering, grouping and formatting large lists.
Render less, load less
- Use
lwc:iffor things users rarely open. Content inside a falselwc:ifisn't created at all; hiding with CSS still builds it. - Always use stable keys in
for:each– the record Id, never the array index – so LWC can reuse rows instead of rebuilding them. - Don't render thousands of rows. Page the data, or use
lightning-datatablewithenable-infinite-loadingand load more ononloadmore. - Load heavy parts on demand. Tabs load their content when opened; dynamic components (
lwc:componentwithlwc:is) download a component's code only when needed. - Never set state in
renderedCallbackwithout a guard – it triggers another render and can loop.
Server calls: cache, batch, debounce
| Practice | Why |
|---|---|
Mark read-only Apex @AuraEnabled(cacheable=true) | results are cached on the client and can be used with @wire |
| Prefer Lightning Data Service (getRecord, lightning-record-form) for single records | shared cache across components, no Apex to maintain |
| One Apex call returning a wrapper instead of five small calls | fewer round trips; each call has overhead |
| Debounce search and auto-save | one call when the user pauses, not one per keystroke |
| Query only the fields and rows you show | smaller payloads, faster serialisation |
Cleanup and memory
Anything you start, stop in disconnectedCallback: setInterval/setTimeout, window/document listeners, Lightning Message Service and empApi subscriptions, ResizeObserver/IntersectionObserver instances, and third-party library objects (chart.destroy()). Lightning pages are single-page apps: users navigate for hours without a reload, so leaks pile up.
Conventions that age well
- Names: components in camelCase folders (
accountSearch), used as kebab-case tags (c-account-search); handlers namedhandleSomething; booleans asisLoading,hasErrors,canEdit. - Constants in UPPER_SNAKE_CASE at module level:
const PAGE_SIZE = 50; - Order inside the class: public
@apiproperties, private fields,@wires, lifecycle hooks, getters, handlers, helpers. - Small components with one job; shared logic in utility modules; business rules in pure, tested functions.
- Labels, not literals: user-facing text in custom labels so it can be translated.
- Lint and format on save: Salesforce projects ship with
@salesforce/eslint-config-lwcand Prettier. Treat lint warnings (no-async-operation,no-api-reassignments,no-inner-html) as review comments from a senior developer.
Practice
Try it yourself
A component wires an Apex method that returns 2,000 cases, and a getter filters them by the search term. Typing feels slow. Name two improvements.
Show answer
Debounce the search input and compute the filtered list in the handler (store it in a field) instead of in a getter; better still, filter on the server with a
LIMITand paginate so the browser never holds 2,000 rows.Why is
key={index}a problem in a list where users can delete rows?Show answer
After a delete, every row below gets a new index, so LWC thinks they're different items and re-creates them – losing input state and wasting work. Use the record Id.
Quick check
When does a template getter run?
On every render of the component, whatever caused it.
What does cacheable=true give you?
Client-side caching and the ability to use the method with @wire.
Is content inside a false lwc:if created?
No – it isn't rendered at all.
Summary
- Keep getters cheap; compute expensive results when inputs change.
- Render only what's needed, with stable keys and paged data.
- Cache, batch and debounce server calls; prefer LDS for single records.
- Clean up everything you start; follow naming conventions and lint rules.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.