DEVELOPMENT SALESFORCE

Lightning Web Components Performance: From Sluggish to Snappy

Back to Articles

Lightning Web Components are powerful, but it's easy to build components that frustrate users with slow rendering and laggy interactions. We've optimized dozens of LWCs, and here are the techniques that make the biggest difference.

Diagnosing Performance Issues

Before optimizing, identify what's actually slow:

Use Chrome DevTools Performance Tab

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Click Record, interact with your component, then Stop
  4. Look for long tasks (red triangles) and JavaScript execution time

Enable Lightning Debug Mode

In Setup → Debug Mode → Enable for your user. This gives you better stack traces and component timing.

Check the Lightning Inspector

The Salesforce Lightning Inspector Chrome extension shows component lifecycle events and performance metrics.

Problem 1: Too Many Wire Calls

Each @wire decorator makes an Apex call. Multiple wires mean multiple round trips:

AccountViewer.js Bad Practice
// ❌ BAD: Three separate server calls
@wire(getAccount, { accountId: '$recordId' })
account;

@wire(getContacts, { accountId: '$recordId' })
contacts;

@wire(getOpportunities, { accountId: '$recordId' })
opportunities;

Combine them into one call:

AccountViewer.js Best Practice
// ✅ GOOD: One server call returns all data
@wire(getAccountWithRelated, { accountId: '$recordId' })
wiredData({ error, data }) {
    if (data) {
        this.account = data.account;
        this.contacts = data.contacts;
        this.opportunities = data.opportunities;
    }
}

// Apex method
@AuraEnabled(cacheable=true)
public static AccountWrapper getAccountWithRelated(Id accountId) {
    return new AccountWrapper(
        [SELECT Id, Name, Industry FROM Account WHERE Id = :accountId],
        [SELECT Id, Name, Email FROM Contact WHERE AccountId = :accountId LIMIT 100],
        [SELECT Id, Name, Amount, StageName FROM Opportunity WHERE AccountId = :accountId LIMIT 50]
    );
}

Problem 2: Rendering Large Lists

Rendering thousands of items kills performance. Use pagination or virtualization:

RecordList.html Bad Practice
// ❌ BAD: Render all 5,000 records at once
<template for:each={allRecords} for:item="record">
    <c-record-card key={record.Id} record={record}></c-record-card>
</template>
RecordList.html Best Practice
// ✅ GOOD: Paginate with "Load More"
<template for:each={visibleRecords} for:item="record">
    <c-record-card key={record.Id} record={record}></c-record-card>
</template>

<template if:true={hasMore}>
    <lightning-button label="Load More" onclick={handleLoadMore}></lightning-button>
</template>
RecordList.js Pagination Logic
// JavaScript
PAGE_SIZE = 50;
currentPage = 1;

get visibleRecords() {
    return this.allRecords.slice(0, this.currentPage * this.PAGE_SIZE);
}

get hasMore() {
    return this.visibleRecords.length < this.allRecords.length;
}

handleLoadMore() {
    this.currentPage++;
}

Problem 3: Expensive Getters

Getters run on every render. Complex calculations in getters cause performance issues:

DataProcessor.js Bad Practice
// ❌ BAD: Complex calculation runs on every render
get processedData() {
    return this.rawData.map(item => {
        return {
            ...item,
            score: this.calculateComplexScore(item),
            formatted: this.formatAllFields(item),
            // More expensive operations...
        };
    });
}
DataProcessor.js Best Practice
// ✅ GOOD: Cache the result
_processedData;

get processedData() {
    if (!this._processedData && this.rawData) {
        this._processedData = this.rawData.map(item => {
            return {
                ...item,
                score: this.calculateComplexScore(item),
                formatted: this.formatAllFields(item),
            };
        });
    }
    return this._processedData;
}

// Clear cache when raw data changes
@wire(getData)
wiredData({ data }) {
    if (data) {
        this.rawData = data;
        this._processedData = null; // Invalidate cache
    }
}

Problem 4: Unnecessary Re-renders

Changing any @track property triggers a re-render. Batch your updates:

StateManagement.js Bad Practice
// ❌ BAD: Three separate re-renders
this.isLoading = false;
this.data = result;
this.error = null;
StateManagement.js Best Practice
// ✅ GOOD: Update object properties (single re-render)
this.state = {
    ...this.state,
    isLoading: false,
    data: result,
    error: null
};

Or use a single state object from the start:

StateManagement.js State Object
@track state = {
    isLoading: false,
    data: null,
    error: null
};

// Update all at once
updateState(updates) {
    this.state = { ...this.state, ...updates };
}

Problem 5: Missing Cacheable

If your Apex method returns the same data for the same inputs, make it cacheable:

ProductController.cls Bad Practice
// ❌ Missing caching - calls server every time
@AuraEnabled
public static List<Product__c> getProducts() {
    return [SELECT Id, Name, Price__c FROM Product__c];
}
ProductController.cls Best Practice
// ✅ With caching - uses client-side cache when possible
@AuraEnabled(cacheable=true)
public static List<Product__c> getProducts() {
    return [SELECT Id, Name, Price__c FROM Product__c];
}

Important: Only use cacheable=true for methods that:

  • Don't modify data
  • Return consistent results for the same inputs
  • Are okay with slightly stale data

Problem 6: Heavy DOM Operations

Manipulating the DOM directly (querying elements, changing styles) is expensive. Let the framework handle it:

DOMManipulation.js Bad Practice
// ❌ BAD: Direct DOM manipulation in a loop
this.template.querySelectorAll('.item').forEach(el => {
    if (this.selectedIds.includes(el.dataset.id)) {
        el.classList.add('selected');
    } else {
        el.classList.remove('selected');
    }
});
ItemList.html Best Practice
// ✅ GOOD: Let the template handle it
<template for:each={items} for:item="item">
    <div key={item.id} class={item.cssClass}>{item.name}</div>
</template>

// In JS, compute cssClass as part of data transformation
get items() {
    return this.rawItems.map(item => ({
        ...item,
        cssClass: this.selectedIds.includes(item.id) ? 'item selected' : 'item'
    }));
}

Real Performance Wins

We recently optimized a client's account hierarchy viewer component:

Before optimization:

  • Initial load: 4.2 seconds
  • 5 separate wire calls
  • Rendering 2,000 nodes at once
  • Getters recalculating on every click

After optimization:

  • Initial load: 0.8 seconds
  • 1 combined wire call with caching
  • Virtual scrolling showing only visible nodes
  • Memoized calculations

80% faster load time, and users actually use the feature now.

Quick Wins Checklist

  • ✅ Combine multiple wire calls into one
  • ✅ Add cacheable=true to read-only Apex methods
  • ✅ Paginate lists with more than 100 items
  • ✅ Cache expensive getter calculations
  • ✅ Batch state updates to reduce re-renders
  • ✅ Use key attribute in for:each loops
  • ✅ Lazy load child components with lwc:if

Need help optimizing your Lightning components? Our custom development services include performance optimization. Let's make your components fast.