Performance Optimization Guide for LWC
Comprehensive guide to optimizing Lightning Web Component performance, including dark mode implementation, lazy loading, and rendering optimization.
Table of Contents
- Dark Mode Implementation
- Rendering Performance
- Lazy Loading
- Data Management
- Event Optimization
- Memory Management
- Bundle Size Optimization
- Performance Testing
- Common Anti-Patterns
Dark Mode Implementation
Dark mode is exclusive to SLDS 2 themes. Components must use global styling hooks to support light/dark theme switching.
Complete SLDS 2 Color Token Reference
Surface Colors
| Token |
Light Mode |
Dark Mode |
Purpose |
--slds-g-color-surface-1 |
#FFFFFF |
#0B0B0B |
Primary surface (body background) |
--slds-g-color-surface-2 |
#F3F3F3 |
#181818 |
Secondary surface |
--slds-g-color-surface-3 |
#E5E5E5 |
#2B2B2B |
Tertiary surface |
--slds-g-color-surface-4 |
#C9C9C9 |
#3E3E3E |
Quaternary surface |
Container Colors
| Token |
Light Mode |
Dark Mode |
Purpose |
--slds-g-color-surface-container-1 |
#FAFAFA |
#1A1A1A |
Card backgrounds, panels |
--slds-g-color-surface-container-2 |
#F7F7F7 |
#232323 |
Nested containers |
--slds-g-color-surface-container-3 |
#F3F3F3 |
#2E2E2E |
Deep nesting |
Text Colors
| Token |
Light Mode |
Dark Mode |
Purpose |
--slds-g-color-on-surface |
#181818 |
#FAFAFA |
Primary text |
--slds-g-color-on-surface-1 |
#444444 |
#C9C9C9 |
Secondary text |
--slds-g-color-on-surface-2 |
#706E6B |
#A0A0A0 |
Muted/disabled text |
--slds-g-color-on-surface-inverse |
#FFFFFF |
#181818 |
Inverse text (buttons, badges) |
Border Colors
| Token |
Light Mode |
Dark Mode |
Purpose |
--slds-g-color-border-1 |
#C9C9C9 |
#444444 |
Primary borders |
--slds-g-color-border-2 |
#E5E5E5 |
#3E3E3E |
Secondary borders (dividers) |
Brand Colors
| Token |
Light Mode |
Dark Mode |
Purpose |
--slds-g-color-brand-1 |
#0176D3 |
#1B96FF |
Primary brand (buttons, links) |
--slds-g-color-brand-2 |
#014486 |
#0B5CAB |
Brand hover/active states |
Status Colors
| Token |
Light Mode |
Dark Mode |
Purpose |
--slds-g-color-success-1 |
#2E844A |
#45C65A |
Success states |
--slds-g-color-error-1 |
#EA001E |
#FE5C4C |
Error states |
--slds-g-color-warning-1 |
#FFB75D |
#FFB75D |
Warning states |
--slds-g-color-info-1 |
#0176D3 |
#1B96FF |
Info states |
Spacing Tokens
| Token |
Value (rem) |
Value (px) |
--slds-g-spacing-0 |
0 |
0 |
--slds-g-spacing-1 |
0.125 |
2 |
--slds-g-spacing-2 |
0.25 |
4 |
--slds-g-spacing-3 |
0.5 |
8 |
--slds-g-spacing-4 |
0.75 |
12 |
--slds-g-spacing-5 |
1 |
16 |
--slds-g-spacing-6 |
1.5 |
24 |
--slds-g-spacing-7 |
2 |
32 |
--slds-g-spacing-8 |
3 |
48 |
Migration Examples
Before: SLDS 1 (Hardcoded Colors)
/* accountCard.css - SLDS 1 (Deprecated) */
.card {
background-color: #ffffff;
color: #333333;
border: 1px solid #dddddd;
border-radius: 4px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header {
color: #000000;
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
.card-text {
color: #666666;
font-size: 14px;
}
.card-link {
color: #0176d3;
}
.card-link:hover {
color: #014486;
text-decoration: underline;
}
After: SLDS 2 (Dark Mode Ready)
/* accountCard.css - SLDS 2 */
.card {
background-color: var(--slds-g-color-surface-container-1, #ffffff);
color: var(--slds-g-color-on-surface, #181818);
border: 1px solid var(--slds-g-color-border-2, #e5e5e5);
border-radius: var(--slds-g-radius-border-2, 0.25rem);
padding: var(--slds-g-spacing-5, 1rem);
box-shadow: 0 2px 4px var(--slds-g-color-border-1, rgba(0, 0, 0, 0.1));
}
.card-header {
color: var(--slds-g-color-on-surface, #181818);
font-size: var(--slds-g-font-size-5, 1rem);
font-weight: var(--slds-g-font-weight-bold, 700);
margin-bottom: var(--slds-g-spacing-3, 0.5rem);
}
.card-text {
color: var(--slds-g-color-on-surface-1, #444444);
font-size: var(--slds-g-font-size-3, 0.875rem);
}
.card-link {
color: var(--slds-g-color-brand-1, #0176d3);
}
.card-link:hover {
color: var(--slds-g-color-brand-2, #014486);
text-decoration: underline;
}
Component-Level Example
// darkModeCard.js
import { LightningElement } from 'lwc';
export default class DarkModeCard extends LightningElement {
// No JavaScript changes needed for dark mode!
// All theming is handled via CSS variables
}
<!-- darkModeCard.html -->
<template>
<div class="card">
<div class="card-header">
<h2 class="card-title">Account Details</h2>
</div>
<div class="card-body">
<p class="card-text">This card automatically adapts to light/dark mode</p>
<a href="#" class="card-link">Learn more</a>
</div>
</div>
</template>
/* darkModeCard.css */
.card {
background-color: var(--slds-g-color-surface-container-1, #ffffff);
border: 1px solid var(--slds-g-color-border-2, #e5e5e5);
border-radius: var(--slds-g-radius-border-2, 0.25rem);
padding: var(--slds-g-spacing-5, 1rem);
}
.card-header {
border-bottom: 1px solid var(--slds-g-color-border-2, #e5e5e5);
margin-bottom: var(--slds-g-spacing-4, 0.75rem);
padding-bottom: var(--slds-g-spacing-3, 0.5rem);
}
.card-title {
color: var(--slds-g-color-on-surface, #181818);
font-size: var(--slds-g-font-size-5, 1rem);
font-weight: var(--slds-g-font-weight-bold, 700);
margin: 0;
}
.card-body {
color: var(--slds-g-color-on-surface-1, #444444);
}
.card-text {
margin-bottom: var(--slds-g-spacing-4, 0.75rem);
}
.card-link {
color: var(--slds-g-color-brand-1, #0176d3);
text-decoration: none;
}
.card-link:hover {
color: var(--slds-g-color-brand-2, #014486);
text-decoration: underline;
}
Validation Script
# Check for hardcoded colors in CSS
grep -r "#[0-9A-Fa-f]\{3,6\}" force-app/main/default/lwc/ --include="*.css"
# Check for rgb/rgba values
grep -r "rgb\|rgba" force-app/main/default/lwc/ --include="*.css"
# Install SLDS Linter
npm install -g @salesforce-ux/slds-linter
# Run validation
slds-linter lint force-app/main/default/lwc/
Rendering Performance
Conditional Rendering
// BAD: Re-renders entire list
<template for:each={allItems} for:item="item">
<div if:true={item.visible} key={item.id}>
{item.name}
</div>
</template>
// GOOD: Filter before rendering
get visibleItems() {
return this.allItems.filter(item => item.visible);
}
<template for:each={visibleItems} for:item="item">
<div key={item.id}>{item.name}</div>
</template>
Use lwc:if for Large Blocks
<!-- lwc:if removes from DOM (better for large blocks) -->
<template lwc:if={showDashboard}>
<c-dashboard data={dashboardData}></c-dashboard>
</template>
<!-- if:true hides with CSS (better for frequent toggling) -->
<div if:true={showMessage} class="message">
{message}
</div>
Key Directive for Lists
<!-- CRITICAL: Use unique, stable keys -->
<template for:each={accounts} for:item="account">
<div key={account.Id}> <!-- Use record ID, not index -->
{account.Name}
</div>
</template>
Getter Caching
// BAD: Recalculates on every render
get formattedValue() {
return this.expensiveCalculation(this.data);
}
// GOOD: Cache the result
@track _cachedValue;
_cacheKey;
get formattedValue() {
const currentKey = JSON.stringify(this.data);
if (this._cacheKey !== currentKey) {
this._cacheKey = currentKey;
this._cachedValue = this.expensiveCalculation(this.data);
}
return this._cachedValue;
}
Avoid renderedCallback Loops
// BAD: Infinite loop
renderedCallback() {
this.count++; // Triggers re-render
}
// GOOD: Guard against loops
renderedCallback() {
if (!this._rendered) {
this._rendered = true;
this.initializeChart();
}
}
Lazy Loading
Dynamic Imports
// accountManager.js
export default class AccountManager extends LightningElement {
@track showCharts = false;
chartModule;
async handleShowCharts() {
if (!this.chartModule) {
// Lazy load chart component
this.chartModule = await import('c/accountChart');
}
this.showCharts = true;
}
}
Intersection Observer for Lazy Loading
// lazyImageLoader.js
export default class LazyImageLoader extends LightningElement {
@api src;
@api alt;
isVisible = false;
observer;
renderedCallback() {
if (!this.observer) {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.isVisible = true;
this.observer.disconnect();
}
});
}, { rootMargin: '50px' });
const img = this.template.querySelector('img');
if (img) {
this.observer.observe(img);
}
}
}
get imageSrc() {
return this.isVisible ? this.src : 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
disconnectedCallback() {
if (this.observer) {
this.observer.disconnect();
}
}
}
<!-- lazyImageLoader.html -->
<template>
<img src={imageSrc} alt={alt} loading="lazy">
</template>
Virtual Scrolling
// virtualList.js
export default class VirtualList extends LightningElement {
@api items = [];
@track visibleItems = [];
itemHeight = 50;
containerHeight = 500;
scrollTop = 0;
get visibleCount() {
return Math.ceil(this.containerHeight / this.itemHeight);
}
get startIndex() {
return Math.floor(this.scrollTop / this.itemHeight);
}
get endIndex() {
return Math.min(
this.startIndex + this.visibleCount + 1,
this.items.length
);
}
get paddingTop() {
return this.startIndex * this.itemHeight;
}
get paddingBottom() {
return (this.items.length - this.endIndex) * this.itemHeight;
}
connectedCallback() {
this.updateVisibleItems();
}
handleScroll(event) {
this.scrollTop = event.target.scrollTop;
this.updateVisibleItems();
}
updateVisibleItems() {
this.visibleItems = this.items.slice(
this.startIndex,
this.endIndex
);
}
}
<!-- virtualList.html -->
<template>
<div class="container"
style={containerStyle}
onscroll={handleScroll}>
<div style={paddingTopStyle}></div>
<template for:each={visibleItems} for:item="item">
<div key={item.id} class="item">
{item.name}
</div>
</template>
<div style={paddingBottomStyle}></div>
</div>
</template>
Data Management
Debouncing
// searchComponent.js
export default class SearchComponent extends LightningElement {
searchTerm = '';
delayTimeout;
handleSearchChange(event) {
const searchTerm = event.target.value;
// Clear previous timeout
clearTimeout(this.delayTimeout);
// Set new timeout (300ms debounce)
this.delayTimeout = setTimeout(() => {
this.performSearch(searchTerm);
}, 300);
}
async performSearch(term) {
try {
const results = await searchAccounts({ searchTerm: term });
this.results = results;
} catch (error) {
this.handleError(error);
}
}
disconnectedCallback() {
clearTimeout(this.delayTimeout);
}
}
Throttling
// scrollTracker.js
export default class ScrollTracker extends LightningElement {
lastScrollTime = 0;
throttleDelay = 100;
handleScroll(event) {
const now = Date.now();
if (now - this.lastScrollTime >= this.throttleDelay) {
this.lastScrollTime = now;
this.processScroll(event);
}
}
processScroll(event) {
// Handle scroll logic
console.log('Scroll position:', event.target.scrollTop);
}
}
Caching Wire Results
// accountList.js
export default class AccountList extends LightningElement {
@api recordId;
wiredAccountsResult;
@wire(getAccounts, { accountId: '$recordId' })
wiredAccounts(result) {
this.wiredAccountsResult = result; // Cache for refreshApex
if (result.data) {
this.accounts = result.data;
} else if (result.error) {
this.error = result.error;
}
}
async handleRefresh() {
// Refresh cached wire result
await refreshApex(this.wiredAccountsResult);
}
}
Event Optimization
Event Delegation
// BAD: Multiple event listeners
<template for:each={items} for:item="item">
<button key={item.id} onclick={handleClick} data-id={item.id}>
{item.name}
</button>
</template>
// GOOD: Single delegated listener
<div onclick={handleContainerClick}>
<template for:each={items} for:item="item">
<button key={item.id} data-id={item.id}>
{item.name}
</button>
</template>
</div>
handleContainerClick(event) {
if (event.target.tagName === 'BUTTON') {
const itemId = event.target.dataset.id;
this.processClick(itemId);
}
}
Prevent Event Bubbling
handleClick(event) {
event.stopPropagation(); // Stop bubbling
event.preventDefault(); // Prevent default action
// Process event
}
Memory Management
Cleanup in disconnectedCallback
export default class ResourceManager extends LightningElement {
subscription;
intervalId;
observer;
connectedCallback() {
// Subscribe to events
this.subscription = subscribe(
this.messageContext,
CHANNEL,
this.handleMessage
);
// Set interval
this.intervalId = setInterval(() => {
this.updateData();
}, 5000);
// Create observer
this.observer = new IntersectionObserver(
this.handleIntersection
);
}
disconnectedCallback() {
// CRITICAL: Clean up all resources
if (this.subscription) {
unsubscribe(this.subscription);
this.subscription = null;
}
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
Remove Event Listeners
export default class EventManager extends LightningElement {
boundHandler;
connectedCallback() {
this.boundHandler = this.handleResize.bind(this);
window.addEventListener('resize', this.boundHandler);
}
disconnectedCallback() {
window.removeEventListener('resize', this.boundHandler);
}
handleResize() {
// Handle resize
}
}
Bundle Size Optimization
Code Splitting
// Import only what you need
import { getRecord } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Account.Name';
// Don't import entire modules
// BAD: import * as uiRecordApi from 'lightning/uiRecordApi';
Minimize Dependencies
// BAD: Import heavy library for simple task
import moment from 'moment';
get formattedDate() {
return moment(this.date).format('MM/DD/YYYY');
}
// GOOD: Use native APIs
get formattedDate() {
return new Intl.DateTimeFormat('en-US').format(new Date(this.date));
}
Performance Testing
Chrome DevTools Performance Tab
// Add performance marks
export default class PerformanceTracked extends LightningElement {
connectedCallback() {
performance.mark('component-start');
this.initializeComponent();
performance.mark('component-end');
performance.measure(
'component-initialization',
'component-start',
'component-end'
);
const measure = performance.getEntriesByName('component-initialization')[0];
console.log('Initialization took:', measure.duration, 'ms');
}
}
Lighthouse Audit
lighthouse https://your-org.lightning.force.com --only-categories=performance
Custom Performance Metrics
export default class MetricsTracker extends LightningElement {
connectedCallback() {
// Track time to interactive
const startTime = performance.now();
this.loadData().then(() => {
const endTime = performance.now();
console.log('Time to interactive:', endTime - startTime, 'ms');
});
}
}
Common Anti-Patterns
1. Excessive Wire Calls
// BAD: Multiple wire calls for related data
@wire(getAccount, { accountId: '$recordId' }) account;
@wire(getContacts, { accountId: '$recordId' }) contacts;
@wire(getOpportunities, { accountId: '$recordId' }) opportunities;
// GOOD: Single wire call with joined data
@wire(getAccountWithRelated, { accountId: '$recordId' })
wiredData({ data, error }) {
if (data) {
this.account = data.account;
this.contacts = data.contacts;
this.opportunities = data.opportunities;
}
}
2. Updating Tracked Properties in Getters
// BAD: Side effects in getter
@track count = 0;
get message() {
this.count++; // Causes infinite re-render!
return `Count: ${this.count}`;
}
// GOOD: Pure getter
get message() {
return `Count: ${this.count}`;
}
3. Not Using @track Wisely
// BAD: Over-using @track
@track simpleValue = 'hello';
@track anotherValue = 42;
// GOOD: Only track complex objects
simpleValue = 'hello'; // Primitives don't need @track
@track complexObject = { nested: { value: 42 } };
4. Heavy Operations in renderedCallback
// BAD: Heavy calculation every render
renderedCallback() {
this.calculateComplexMetrics(); // Expensive!
}
// GOOD: Calculate only when data changes
@track _dataVersion = 0;
_renderedVersion = -1;
renderedCallback() {
if (this._renderedVersion !== this._dataVersion) {
this._renderedVersion = this._dataVersion;
this.calculateComplexMetrics();
}
}
handleDataChange() {
this._dataVersion++;
}
Performance Checklist
Related Resources