DEV Community

Selavina B
Selavina B

Posted on

LWC/Aura UI Bugs and Performance Bottlenecks

Problem statement
Lightning components load slowly, fail with large datasets, or throw runtime errors due to inefficient Apex calls, poor state management, and missing error handling.

Step 1 Identify the bottleneck first (quick checklist)
Most slow/buggy Lightning UIs come from one (or more) of these:

  1. Too many Apex calls (N+1 calls, call per row, call per keystroke) 2.Returning too much data (querying 10k+ records, too many fields)
  2. No pagination / no server-side filtering
  3. No caching (@AuraEnabled(cacheable=true) missing)
  4. Bad state management (re-render loops, heavy getters, repeated array cloning)
  5. Missing error handling (uncaught promise errors → blank UI / red error) 7.Inefficient SOQL (non-selective filters, no indexes, sorting huge sets) We’ll fix these with a solid baseline architecture.

Step 2 Fix Apex: cacheable + paginated + minimal fields
Apex Controller (server-side pagination + search)
This avoids loading thousands of rows and keeps the UI fast.
ContactBrowserController.cls
`public with sharing class ContactBrowserController {
public class PageResult {
@AuraEnabled public List records;
@AuraEnabled public Integer total;
@AuraEnabled public String nextCursor; // for keyset pagination (optional)
}

@AuraEnabled(cacheable=true)
public static PageResult fetchContacts(Integer pageSize, Integer pageNumber, String searchKey) {
    pageSize = (pageSize == null || pageSize <= 0) ? 25 : Math.min(pageSize, 200);
    pageNumber = (pageNumber == null || pageNumber <= 0) ? 1 : pageNumber;

    String likeKey = String.isBlank(searchKey) ? null : ('%' + searchKey.trim() + '%');

    // Count query (for pagination UI)
    Integer totalCount;
    if (likeKey == null) {
        totalCount = [SELECT COUNT() FROM Contact WHERE IsDeleted = false];
    } else {
        totalCount = [
            SELECT COUNT()
            FROM Contact
            WHERE IsDeleted = false
            AND (Name LIKE :likeKey OR Email LIKE :likeKey)
        ];
    }

    Integer offsetRows = (pageNumber - 1) * pageSize;

    List<Contact> rows;
    if (likeKey == null) {
        rows = [
            SELECT Id, Name, Email, Phone, Account.Name
            FROM Contact
            WHERE IsDeleted = false
            ORDER BY LastModifiedDate DESC
            LIMIT :pageSize OFFSET :offsetRows
        ];
    } else {
        rows = [
            SELECT Id, Name, Email, Phone, Account.Name
            FROM Contact
            WHERE IsDeleted = false
            AND (Name LIKE :likeKey OR Email LIKE :likeKey)
            ORDER BY LastModifiedDate DESC
            LIMIT :pageSize OFFSET :offsetRows
        ];
    }

    PageResult result = new PageResult();
    result.records = rows;
    result.total = totalCount;
    result.nextCursor = null; // kept for future keyset approach
    return result;
}
Enter fullscreen mode Exit fullscreen mode

}`

Why this helps
• You fetch only what you need (small page).
• You limit fields (fewer bytes, faster).
• You cache results for identical parameters.
Note: OFFSET can become slow at very high page numbers. If you need huge datasets, move to keyset pagination (cursor based). I can provide that too.

Step 3 LWC: debounce search + show spinner + handle errors
LWC HTML (datatable + search + paging)
contactBrowser.html

<template>
    <lightning-card title="Contacts" icon-name="standard:contact">
        <div class="slds-p-horizontal_medium slds-p-top_small">
            <lightning-input
                type="search"
                label="Search (Name or Email)"
                value={searchKey}
                onchange={handleSearchChange}>
            </lightning-input>

            <template if:true={isLoading}>
                <div class="slds-m-top_small">
                    <lightning-spinner alternative-text="Loading..." size="small"></lightning-spinner>
                </div>
            </template>

            <template if:true={errorMessage}>
                <div class="slds-m-top_small slds-text-color_error">
                    {errorMessage}
                </div>
            </template>

            <lightning-datatable
                key-field="Id"
                data={rows}
                columns={columns}
                hide-checkbox-column
                class="slds-m-top_small">
            </lightning-datatable>

            <div class="slds-m-top_small slds-grid slds-grid_align-spread slds-grid_vertical-align-center">
                <div>
                    Page {pageNumber} of {totalPages} • Total: {total}
                </div>

                <div class="slds-button-group" role="group">
                    <lightning-button label="Prev" onclick={prevPage} disabled={disablePrev}></lightning-button>
                    <lightning-button label="Next" onclick={nextPage} disabled={disableNext}></lightning-button>
                </div>
            </div>
        </div>
    </lightning-card>
</template>
Enter fullscreen mode Exit fullscreen mode

LWC JS (imperative Apex + debounce + safe state updates)
contactBrowser.js

import { LightningElement, track } from 'lwc';
import fetchContacts from '@salesforce/apex/ContactBrowserController.fetchContacts';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

export default class ContactBrowser extends LightningElement {
    columns = [
        { label: 'Name', fieldName: 'Name' },
        { label: 'Email', fieldName: 'Email' },
        { label: 'Phone', fieldName: 'Phone' },
        { label: 'Account', fieldName: 'AccountName' }
    ];

    @track rows = [];
    total = 0;

    pageSize = 25;
    pageNumber = 1;

    searchKey = '';
    isLoading = false;
    errorMessage = '';

    debounceTimer;

    connectedCallback() {
        this.load();
    }

    get totalPages() {
        return Math.max(1, Math.ceil(this.total / this.pageSize));
    }

    get disablePrev() {
        return this.pageNumber <= 1 || this.isLoading;
    }

    get disableNext() {
        return this.pageNumber >= this.totalPages || this.isLoading;
    }

    async load() {
        this.isLoading = true;
        this.errorMessage = '';

        try {
            const res = await fetchContacts({
                pageSize: this.pageSize,
                pageNumber: this.pageNumber,
                searchKey: this.searchKey
            });

            // Flatten Account.Name for datatable
            this.rows = (res.records || []).map(r => ({
                ...r,
                AccountName: r.Account ? r.Account.Name : ''
            }));
            this.total = res.total || 0;

            // Clamp pageNumber if total changed drastically
            if (this.pageNumber > this.totalPages) {
                this.pageNumber = this.totalPages;
            }
        } catch (e) {
            this.errorMessage = this.reduceError(e);
            this.rows = [];
            this.total = 0;

            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Error loading contacts',
                    message: this.errorMessage,
                    variant: 'error'
                })
            );
        } finally {
            this.isLoading = false;
        }
    }

    handleSearchChange(event) {
        this.searchKey = event.target.value;

        // Debounce to avoid Apex call on every keystroke
        window.clearTimeout(this.debounceTimer);
        this.debounceTimer = window.setTimeout(() => {
            this.pageNumber = 1;
            this.load();
        }, 400);
    }

    nextPage() {
        if (this.pageNumber < this.totalPages) {
            this.pageNumber += 1;
            this.load();
        }
    }

    prevPage() {
        if (this.pageNumber > 1) {
            this.pageNumber -= 1;
            this.load();
        }
    }

    reduceError(e) {
        // Works for Apex + JS errors
        if (Array.isArray(e?.body)) return e.body.map(x => x.message).join(', ');
        return e?.body?.message || e?.message || 'Unknown error';
    }
}
Enter fullscreen mode Exit fullscreen mode

What these fixes
• Debounce prevents “Apex call per keystroke”
• Spinner prevents “UI looks frozen”
• Toast + error message prevents “silent failure”
• Pagination prevents huge dataset rendering

Step 4 Fix common performance killers (must-do rules)
Rule A: Don’t call Apex in loops
Bad: call Apex once per row
Good: one Apex call returns all needed data for the page.

Rule B: Query only needed fields
Every extra field increases payload size and JSON parse time.

Rule C: Prefer cacheable reads
Use:
• @AuraEnabled(cacheable=true) for read methods
• refreshApex() when you actually need a refresh (wired approach)

Rule D: Avoid heavy getters causing re-render storms
If your getter does heavy logic or map/filter each render, move that computation to a one-time transform in load().

Step 5 If you’re using Aura: add client-side caching + proper error handling
Aura server action caching (storable)

// Aura controller
let action = component.get("c.fetchContacts");
action.setParams({ pageSize: 25, pageNumber: 1, searchKey: '' });

// Cache response (works for cacheable methods)
action.setStorable();

action.setCallback(this, function(response){
    let state = response.getState();
    if(state === "SUCCESS"){
        component.set("v.rows", response.getReturnValue().records);
    } else {
        let errors = response.getError();
        // show toast / log
    }
});
$A.enqueueAction(action);

Enter fullscreen mode Exit fullscreen mode

Step 6 Add a “large data” safety net
When datasets get large, do these:
1.Server-side pagination (already done)
2.Limit pageSize (cap at 200)
3.Consider keyset pagination (cursor based) instead of OFFSET for deep paging
4.Ensure SOQL filters are selective (indexed fields where possible)

Step 7 Quick “production-grade” checklist
• Apex read methods are cacheable=true
• UI uses pagination / infinite scroll (not loading all)
• Search is debounced
• Handle errors with toast + user-friendly message
• Avoid repeated Apex calls; combine queries where possible
• Flatten/transform data once (not repeatedly in getters)
• Validate SOQL selectivity (especially in production volume)

Conclusion
LWC/Aura UI bugs and performance bottlenecks usually happen when components try to load too much data at once, make too many Apex calls, or fail to manage state and errors properly. What works fine with small sandbox data often breaks in production when record volumes grow and network/API delays increase.
To fix this reliably:
• Move heavy work to the server and use server-side filtering + pagination instead of loading thousands of rows.
• Mark read-only Apex methods as @AuraEnabled(cacheable=true) to reduce repeat calls and speed up rendering.
• Prevent unnecessary calls by using debounced search, avoiding Apex calls inside loops, and keeping the UI state minimal.
• Always include proper error handling (try/catch, toast messages, fallback UI) so runtime failures don’t crash the component silently.
• Query only the fields you actually need and use efficient SOQL to keep response payloads small and fast.

Top comments (0)