A component can get its security model right and its data flow right, and still misbehave. It queries an element that isn’t there yet. It races a render cycle with a timeout. It works perfectly on desktop and renders blank on mobile. These bugs don’t come from misunderstanding a single API; they come from misunderstanding when things happen: when the DOM exists, when a render has actually completed, and which runtime a component is executing in.
This is the third and final post in our series on Lightning Web Component (LWC) anti-patterns. The first post covered security and platform compatibility. The second post covered the wire service, public properties, and events. In this post, you’ll learn the most common mistakes in DOM access, asynchronous timing, the component lifecycle, performance, and base component usage.
DOM access anti-patterns
LWC renders each component into its own shadow DOM. A component should only read or modify elements it owns. The patterns below cover the ways that boundary gets crossed, accidentally or not.
Don’t query the global document
Calling document.querySelector() or document.getElementById() searches the entire page instead of your component’s shadow tree. Inside Lightning Web Security, these calls are scoped to your sandbox and won’t find elements owned by other components. Outside that environment (in a Jest test without proper mocking, for instance) they may appear to work but behave differently in production.
1renderedCallback() {
2 // Searches the global document — won't find elements in another component's shadow
3 const field = document.querySelector('.my-input-field');
4 field?.focus();
5}Scope the query to your own template instead:
1renderedCallback() {
2 const field = this.template.querySelector('.my-input-field');
3 field?.focus();
4
5}For a cleaner approach that avoids selector fragility altogether, use lwc:ref:
1<template>
2 <input lwc:ref="inputField" type="text" />
3</template>1renderedCallback() {
2 this.refs.inputField.focus();
3}Don’t set innerHTML
Setting innerHTML is an XSS risk whenever any part of the string comes from user input, and it bypasses LWC’s reactive rendering pipeline; a later template re-render can overwrite or conflict with markup you injected imperatively. Lightning Web Security sanitizes innerHTML on shared elements such as <body>, but it doesn’t protect the markup inside your own component, so that responsibility stays with you. The no-inner-html rule in the LWC ESLint plugin flags this pattern automatically.
1renderedCallback() {
2 const el = this.template.querySelector('.output');
3 el.innerHTML = `<strong>${this.label}</strong>`;
4}Express the markup declaratively in the template instead:
1<template>
2 <div class="output"><strong>{label}</strong></div>
3</template>If you need to render arbitrary HTML from a trusted source, such as rich text from a CMS, use the lightning-formatted-rich-text base component, which applies platform-level sanitization.
Don’t use ID selectors in querySelector
LWC rewrites id attributes in templates into globally unique values at render time, to prevent collisions across components on the same page. The id you wrote in your HTML won’t match what’s in the rendered DOM, so this.template.querySelector('#myId') is unreliable.
1<template>
2 <input id="nameInput" type="text" />
3</template>1renderedCallback() {
2 // The rendered id will be something like "nameInput-123", not "nameInput"
3 const input = this.template.querySelector('#nameInput');
4}Use class selectors, data attributes, or lwc:ref instead:
1<template>
2 <input lwc:ref="nameInput" type="text" />
3</template>1renderedCallback() {
2 const input = this.refs.nameInput;
3}Asynchronous timing anti-patterns
setTimeout shows up as a timing workaround in two very different LWC scenarios, and both are fragile for the same underlying reason: they couple your logic to an arbitrary wall-clock delay instead of to the thing you’re actually waiting for.
Don’t use setTimeout to wait for async data
Wrapping data processing in a timeout, hoping a fetch or state update has “finished” by the time it fires, is unreliable. The timeout can fire before or after the data is ready depending on load, and there’s no signal telling you it actually completed.
1handleUpdate() {
2 this.isLoading = true;
3 // Hoping 100ms is enough for the data to arrive
4 setTimeout(() => {
5 this.processData();
6 this.isLoading = false;
7 }, 100);
8}Use async/await so the work runs exactly when the data is ready:
1data; // holds the loaded data
2async handleUpdate() {
3 this.isLoading = true;
4 try {
5 this.data = await loadData();
6 } finally {
7 this.isLoading = false;
8 }
9}Don’t use setTimeout to wait for a re-render
Setting a reactive property and immediately trying to read the updated DOM also fails, because LWC batches and applies DOM updates asynchronously. setTimeout(..., 0) often appears to work in testing, but it isn’t guaranteed because it depends on timing rather than a signal from the engine, and it breaks under load.
1handleShowPanel() {
2 this.showPanel = true;
3 // DOM hasn't updated yet — querySelector returns null
4 setTimeout(() => {
5 this.template.querySelector('.panel input').focus();
6 }, 0);
7}Use renderedCallback() instead. The engine calls it after every render cycle, guaranteeing the DOM reflects the latest state:
1_shouldFocusInput = false;
2
3handleShowPanel() {
4 this.showPanel = true;
5 this._shouldFocusInput = true;
6}
7
8renderedCallback() {
9 if (this._shouldFocusInput) {
10 this._shouldFocusInput = false;
11 const input = this.template.querySelector('.panel input');
12 input?.focus();
13 }
14}Lifecycle anti-patterns
Several APIs are unavailable, or unreliable, at specific points in a component’s lifecycle. Most lifecycle bugs come from reaching for one of these APIs one hook too early.
Don’t access @api properties in the constructor
The constructor() runs before the parent has set any @api property values. Reading one in the constructor returns undefined, which leads to incorrect initialization.
1export default class MyComponent extends LightningElement {
2 @api recordId;
3
4 constructor() {
5 super();
6 // recordId has not been set yet — this.recordId is undefined
7 this.fetchData(this.recordId);
8 }
9}To be clear about what is fine in a constructor: setting default values for your own class properties is a perfectly normal use of it. The restriction is specifically on reading @api properties (their values haven’t been set yet) and touching the DOM (nothing has rendered; see the next pattern). For initialization logic that depends on an @api value, use connectedCallback(), or better, drive it with @wire, which re-runs automatically when the reactive property changes:
1export default class MyComponent extends LightningElement {
2 @api recordId;
3
4 @wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD] })
5 record;
6}Don’t access the DOM in the constructor or connectedCallback()
The rendering pipeline is asynchronous. When constructor() runs, nothing has been rendered yet, so this.template.querySelector() returns null. connectedCallback() fires when the component is inserted into the DOM tree, but its children have not rendered yet either. On first connection, a query in either hook returns null, so the code either throws a TypeError or, if you use optional chaining, silently does nothing.
1export default class MyComponent extends LightningElement {
2 constructor() {
3 super();
4 // DOM not rendered yet — returns null
5 const input = this.template.querySelector('input');
6 input.focus(); // throws: Cannot read properties of null
7 }
8
9 connectedCallback() {
10 // Child elements don't exist yet, so this returns null
11 const panel = this.template.querySelector('.panel');
12 panel.classList.add('active'); // throws: Cannot read properties of null
13 }
14}Use renderedCallback() for anything that reads or modifies rendered DOM elements, guarded with a flag if the setup should run only once:
1export default class MyComponent extends LightningElement {
2 _initialized = false;
3
4 renderedCallback() {
5 if (this._initialized) return;
6 this._initialized = true;
7
8 const input = this.template.querySelector('input');
9
10 input?.focus();
11
12 }
13}renderedCallback() fires after every render, not just the first, so the guard matters. For DOM updates that should track ongoing data changes rather than run once, let the template handle them declaratively instead of reaching for imperative DOM code at all.
Don’t access this.template.children
this.template.children returns the immediate children of your component’s template root, but the LWC engine makes no guarantee about their structure or order. Reading them directly ties your logic to rendered DOM structure instead of component intent, and that structure can shift with unrelated template changes.
1renderedCallback() {
2 const firstChild = this.template.children[0];
3 firstChild.classList.add('active');
4}Use this.template.querySelector() with a stable selector, or lwc:ref, to reference elements by intent rather than by position:
1<template>
2 <div class="main-panel" lwc:ref="panel"></div>
3</template>1renderedCallback() {
2 this.refs.panel.classList.add('active');
3}Performance anti-patterns
Lightning Web Security introduces proxy overhead at the boundary between the host environment and your component’s sandbox. For most components, this overhead is negligible, but it becomes measurable when a component processes large volumes of data or crosses that boundary repeatedly in a tight loop.
Clone host-environment data before processing it in a loop
Data returned by Apex controllers and Lightning Data Service (LDS) wire adapters originates outside your component’s sandbox. Every property access on that data crosses the proxy boundary. For a handful of fields, this cost is imperceptible. Over thousands of rows, across every sort, filter, and render cycle, it adds up to noticeably degraded responsiveness.
1@wire(getOpportunities)
2wiredOpps({ data }) {
3 if (data) {
4 // Each access to opp.Amount and opp.StageName crosses the proxy boundary
5 this.total = data.reduce((sum, opp) => sum + opp.Amount, 0);
6 this.openOpps = data.filter(opp => opp.StageName !== 'Closed Won');
7 }
8}Clone the data into your own namespace once, on arrival, and process the local copy from then on:
1@wire(getOpportunities)
2wiredOpps({ data }) {
3 if (data) {
4 // Clone once on arrival so subsequent processing uses local objects
5 const local = JSON.parse(JSON.stringify(data));
6 this.total = local.reduce((sum, opp) => sum + opp.Amount, 0);
7 this.openOpps = local.filter(opp => opp.StageName !== 'Closed Won');
8 }
9}Where possible, push the aggregation itself server-side into Apex, so the client receives a precomputed result instead of a large collection to iterate over.
Prefer plain objects and arrays over Set and Map for large collections
Set, Map, and typed arrays carry the same proxy overhead as host-environment data when accessed inside a namespace sandbox. Processing tens of thousands of items through these structures produces performance degradation you wouldn’t see in an equivalent non-sandboxed environment.
1processIds(records) {
2 // Map and Set carry proxy overhead per operation at scale
3 const seen = new Set(); // passed through the loop
4 const index = new Map();
5 records.forEach(r => {
6 seen.add(r.Id);
7 index.set(r.Id, r);
8 });
9 return index;
10}Use plain objects and arrays for large collections instead:
1processIds(records) {
2 const seen = {}; // passed through the loop
3 const index = {};
4 records.forEach(r => {
5 seen[r.Id] = true;
6 index[r.Id] = r;
7 });
8 return index;
9}Reserve Set and Map for small collections, where the overhead is negligible, or for cases where their semantics (non-string keys, insertion-order iteration) are genuinely required.
Base component usage anti-patterns
Base Lightning components behave differently across the runtimes your component can end up in, including Lightning Experience, Lightning Web Runtime (LWR) sites, Experience Cloud, and the Salesforce Mobile App. Several of the following failures are silent: nothing throws, the component just doesn’t do what you expect.
Don’t use platformShowToastEvent outside Lightning Experience
lightning/platformShowToastEvent dispatches a toast using an event-based mechanism that’s only supported in Salesforce Classic, Lightning Experience, and the mobile app. It’s a silent no-op in LWR sites and Experience Cloud. No error; no toast.
1import { ShowToastEvent } from 'lightning/platformShowToastEvent';
2
3handleSuccess() {
4 // Silent no-op in LWR and Experience Cloud
5 this.dispatchEvent(new ShowToastEvent({
6 title: 'Success',
7 message: 'Record saved.',
8 variant: 'success'
9 }));
10}Use lightning/toast instead, which works across all supported containers:
1import LightningToast from 'lightning/toast';
2
3handleSuccess() {
4 LightningToast.show({
5 label: 'Success',
6 message: 'Record saved.',
7 variant: 'success'
8 });
9}Don’t extend unsupported base components
Only two base components officially support class extension: lightning/modal and lightning/datatable. Extending any other base component relies on internal implementation details that can change in any release without notice.
1import LightningCombobox from 'lightning/combobox';
2
3// Extending an unsupported base component
4export default class CustomCombobox extends LightningCombobox {
5 connectedCallback() {
6 super.connectedCallback();
7 this.doCustomSetup();
8 }
9}Wrap the base component instead, and delegate to it through @api properties and events:
1export default class CustomCombobox extends LightningElement {
2 @api label;
3 @api options;
4 @api value;
5
6 handleChange(event) {
7 this.dispatchEvent(new CustomEvent('change', {
8 detail: { value: event.detail.value }
9 }));
10 }
11}1<template>
2 <lightning-combobox
3 label={label}
4 options={options}
5 value={value}
6 onchange={handleChange}>
7 </lightning-combobox>
8</template>Consuming components use c-custom-combobox exactly as they would the base component. The wrapper’s @api properties and change event give it the same surface:
1<c-custom-combobox
2 label="Status"
3 options={statusOptions}
4 value={status}
5 onchange={handleStatusChange}>
6</c-custom-combobox>Don’t read the record ID from the submit event
When lightning-record-form or lightning-record-edit-form creates a new record, the record’s ID doesn’t exist yet at submit time. Reading event.detail.id in an onsubmit handler returns undefined silently, with no error.
1handleSubmit(event) {
2 // undefined for new records — the record hasn't been created yet
3 const newRecordId = event.detail.id;
4 this.navigateToRecord(newRecordId);
5}Use the success event instead, which fires after the record is saved and the server has assigned an ID:
1handleSuccess(event) {
2 const newRecordId = event.detail.id;
3 this.dispatchEvent(new CustomEvent('recordcreated', {
4 detail: { recordId: newRecordId }
5 }));
6}1<lightning-record-form
2 object-api-name="Account"
3 fields={fields}
4 onsuccess={handleSuccess}>
5</lightning-record-form>submit is still the right event for intercepting or modifying a form submission before it goes out; just not for reading the ID that comes back.
Don’t override a base component’s internal SLDS styles
Base components like lightning-button and lightning-input render their internal markup inside the LWC shadow DOM. Their internal class names, such as .slds-button_brand, .slds-input, and similar, are implementation details, not a public API. CSS that reaches into a base component’s shadow to target these classes is unsupported, and it breaks silently whenever SLDS changes internal markup or styling, including changes made for accessibility fixes or SLDS token updates.
1/* Targeting an internal SLDS class inside lightning-button — unsupported */
2.slds-button_brand {
3 background-color: #e07d00 !important;
4}Use the public styling hooks (--slds-* CSS custom properties) that the component exposes as its actual styling API. For example, to restyle a brand lightning-button, set its component hooks (which currently apply only to SLDS 1) from your own stylesheet:
1:host {
2 --slds-c-button-brand-color-background: var(--slds-g-orange-70);
3 --slds-c-button-brand-color-border: var(--slds-g-orange-70);
4}Stick to the public --slds-* hooks. The older --lwc-* hooks are legacy and discouraged, and overriding them can trigger validation warnings. If the component doesn’t expose a hook for what you need, wrap it and style the wrapper, or build the element from scratch if the customization runs deep enough.
Check mobile support before using a base component on a mobile-facing page
The Salesforce Mobile App uses a different rendering pipeline than the desktop browser, and some base components have no mobile implementation, or a reduced one. Using one on a page that also renders in the mobile app fails silently. The component renders incompletely or blank, with no error.
The most common case is lightning-datatable, which is not supported in the Salesforce Mobile App. It renders correctly in a desktop browser and even in a mobile browser’s responsive mode, which makes the failure easy to miss until it’s tested in the native app itself.
1<!-- lightning-datatable is not supported in the Salesforce Mobile App -->
2<template>
3 <lightning-datatable
4 key-field="id"
5 data={products}
6 columns={columns}
7 onrowaction={handleRowAction}>
8 </lightning-datatable>
9</template>Detect the form factor at runtime and render a mobile-appropriate alternative:
1import FORM_FACTOR from '@salesforce/client/formFactor';
2
3export default class MyProductList extends LightningElement {
4 isMobile = FORM_FACTOR === 'Small';
5}1<template lwc:if={isMobile}>
2 <template for:each={products} for:item="product">
3 <c-product-tile key={product.id} record={product}></c-product-tile>
4 </template>
5</template>
6<template lwc:else>
7 <lightning-datatable
8 key-field="id"
9 data={products}
10 columns={columns}
11 onrowaction={handleRowAction}>
12 </lightning-datatable>
13</template>If the mobile and desktop experiences differ substantially, consider rendering multiple templates instead of stacking lwc:if blocks. This approach lets a component ship separate templates and choose between them in a render() method.
Before deploying a custom component to a page that appears in the mobile app, check the Component Reference for mobile support, and test in the native app, since desktop responsive mode doesn’t accurately simulate the mobile runtime.
Conclusion
The patterns in this post share a common root: assuming something is ready before it actually is. The DOM isn’t there yet in the constructor. A render hasn’t finished just because you set a property. An @api value isn’t set until the parent sets it. A base component that works in Lightning Experience isn’t guaranteed to work in LWR, Experience Cloud, or the mobile app.
Across this series, the underlying lesson has been the same: LWC guarantees are specific and narrower than they first appear. Security layers namespace and sanitize more than they block. The wire service is a subscription, not a fetch. And the runtime a component renders in shapes what’s actually available to it. Once those models are correct, most of the anti-patterns in this series stop looking like traps and start looking obvious.
Resources
- Component lifecycle documentation
- Create DOM elements
- lwc:ref documentation
- lightning/toast documentation
- @salesforce/client/formFactor
- @lwc/eslint-plugin-lwc rules
About the author
Tim Dionne is a Principal Member of Technical Staff (PMTS) on the Customer Centric Engineering team. He’s worked on many UI features of Salesforce over the years, starting with VisualForce, Aura Components, and Lightning Web Components with an emphasis on Lightning Web Security and Lightning Data Service.



