Updated recently
Last updated:
Quick Answer: Salesforce Apex vs LWC is a runtime decision, not a preference: Apex runs on the server and is the only place you can query and write data securely, enforce business rules and survive bulk operations; LWC runs in the browser and is where you handle UI state, instant feedback, filtering and anything the user should see without a round trip. Default to doing it in the LWC if no data access or rule is involved, move to Apex the moment security, SOQL, DML or cross-record logic enters the picture — and never enforce your only validation on the client.
Salesforce Apex vs LWC: Where Each One Actually Runs
The confusion starts the moment someone describes LWC as “the front end of Apex”. They are two runtimes with different contracts, and the contract determines every design choice below it.
| Dimension | Apex | LWC |
|---|---|---|
| Execution context | Salesforce server, in a governed transaction | User’s browser (and, for SSR cases, the server rendering the page) |
| Data access | SOQL/SOSL and DML directly, under sharing rules and CRUD/FLS | Only through Apex, wire adapters or the UI API — no direct SOQL |
| Cost per call | Server transaction; counts against governor limits | Network round trip — latency the user feels directly |
| Governing limits | 100 SOQL queries, 150 DML statements, 50,000 query rows, 10s CPU per transaction (60s async) | DOM size, re-render churn, bundle size; no query limits because it cannot query |
| Security posture | Enforced server-side; with sharing plus CRUD/FLS checks |
Assume hostile — anything you expose here can be called directly |
| Best at | Business rules, bulk-safe writes, integrations, reusable logic | Instant feedback, local filtering, form behaviour, optimistic UI |
The row people skip is security. Every @AuraEnabled method is a public API endpoint: a user can invoke it without ever loading your component. Code that assumes “only my LWC calls this” is the most common privilege-escalation path in a Salesforce org, and it is also the reason the answer to Apex vs LWC is never “put it wherever it is quickest”.
The Decision Rules That Actually Work
| Scenario | Where It Belongs | Why |
|---|---|---|
| Show a validation message as the user types a field | LWC | No data access; round-trip latency would ruin it |
| Validate on save before the record is written | Apex | Client validation is advisory; only the server enforces |
| Filter or sort a list already loaded on screen | LWC | Pure client state — no reason to hit the server |
| Query records with sharing and FLS respected | Apex | LWC cannot query; UI API only goes so far |
| Write to several objects in one transaction | Apex | Atomicity and DML limits are server concerns |
| Call an external API | Apex | Credentials, callout limits and retry logic must stay server-side |
| Cross-record calculation over thousands of rows | Apex (batch/queueable) | Browser has the data only if you dragged it all across first |
| Wizard with state across five steps | LWC | UI state; persisting every keystroke to the server is pointless |
A good tie-breaker: does this decision need data the browser does not have, or a rule the user must not be able to break? Apex. Is this a matter of how the page behaves? LWC. And when the answer genuinely is both — a form that validates locally for speed and again on the server for integrity — write it twice. Duplicating a rule across tiers is not a design smell; it is how every secure system works.
The Apex Side: A Controller That Respects Governor Limits
A clean server-side entry point for a component — cacheable for reads, bounded in volume, and explicit about security:
public with sharing class ClaimController {
@AuraEnabled(cacheable=true)
public static List<Claim__c> searchClaims(String status, Integer pageSize) {
// One query, bounded, ordered. Never trust pageSize from the client.
Integer size = (pageSize == null || pageSize > 200) ? 200 : pageSize;
return [
SELECT Id, Name, Status__c, Amount__c, CreatedDate
FROM Claim__c
WHERE Status__c = :status
WITH SECURITY_ENFORCED
ORDER BY CreatedDate DESC
LIMIT :size
];
}
}
Read it with the limits in mind. One method call does one SOQL query — the ceiling is 100 per transaction, and the failure you will meet in production is the beautifully named Too many SOQL queries: 101, usually caused by a query inside a loop. with sharing plus WITH SECURITY_ENFORCED means the caller’s sharing rules and field-level security apply; drop either and your component becomes a data-exposure incident with a friendly UI. cacheable=true lets the framework memoise the result so re-renders do not re-query — the trade is that cached methods cannot modify data, which is exactly the discipline you want on a read path.
The anti-pattern it is replacing, worth seeing written down:
// Do NOT: one query per row inside a loop
for (Claim__c c : claims) {
List<Contact> owners = [SELECT Id FROM Contact WHERE Claim__c = :c.Id];
// 200 claims = 200 queries = LimitException on query 101
}
Fix it with one query keyed on a collection — WHERE Claim__c IN :claimIds — and group the results in memory. This is the single most common Apex failure in Indian Salesforce projects, and it is also, not coincidentally, the number one topic in mid-level technical rounds.
The LWC Side: State, Props and One Round Trip
The component half of the same feature — declarative wiring instead of hand-rolled fetch calls:
import { LightningElement, api, track, wire } from 'lwc';
import searchClaims from '@apex/ClaimController.searchClaims';
export default class ClaimList extends LightningElement {
@api recordId; // public property, set by the parent or record page
@track status = 'Open'; // private reactive state driving the query
@wire(searchClaims, { status: '$status', pageSize: 50 })
claims;
handleFilter(event) {
this.status = event.target.value; // '$status' re-fires the wire for us
}
}
The details that matter. The '$status' syntax makes the wire adapter reactive — change the property and the framework re-executes the Apex call, so you get server data with no manual plumbing. @api marks a property as public API of the component; treat changing it like changing a function signature, because a parent may depend on it. @track is the historical marker for reactive private properties — modern LWC makes private properties reactive automatically, so it is optional now, and keeping it is a readability choice rather than a requirement. For mutations (writes, not reads) you call the Apex method imperatively from an event handler and then use refreshApex to invalidate cached wire results — forget that last part and your table looks correct while being wrong.
One performance habit: batch your reads. A component that calls an Apex method per table row — twenty rows, twenty round trips — is the LWC version of the SOQL-in-a-loop bug, and it feels worse to the user because every one of them is latency. Load the page’s data in one wire, then work locally.
The Four Ways Teams Get This Wrong
- Validation only in LWC. The UI is delightful and the data is wrong the moment anyone calls the endpoint directly, uses the API, or runs a data load. Validate in both places, always.
- Business logic inside the component. Discount rules, eligibility, tax — decided in JavaScript, then re-decided in Apex, then drifting apart over three releases. Rules that must be true belong server-side, in one place.
- Round-tripping for UI state. Saving every accordion expansion and filter keystroke to a record or Apex call. The user waits; the database does not care.
- Apex doing presentation work. Formatting labels, sorting by display value, building HTML strings. That is the browser’s job, and it burns your 10-second CPU budget on trivia.
What Actually Happens at Work
An insurance team shipped a claims list component that worked beautifully in the demo. Each row expanded to show related contacts, and the developer — sensibly wanting fresh data — wired each expansion to an imperative Apex call. Ten visible rows meant ten round trips; scrolling to row thirty meant thirty. On the office network it felt fine. On a broker’s laptop over a hotel connection, the page was unusable: rows appeared in sequence, and double-clicks fired duplicate requests that tripped over each other’s state.
The second problem was subtler. The component filtered claims client-side by “status is Open”, so users saw exactly what they expected. The supporting report, built separately in Apex, applied the same filter but also respected record visibility. The two disagreed — a broker could see a claim in the component that the report insisted did not exist — and it took a week of mutual blame between the report owner and the component owner before anyone thought to check whether the browser was the source of truth. It was not, and had never been.
The fix was unglamorous: one wire call fetching the full working set with paging above fifty rows, local filtering for speed, and every rule that could decide who sees what moved into Apex where sharing and FLS are actually enforced. Two weeks of work to remove code, which is the correct direction. Notice also what the incident did not involve — no framework problem, no platform limit, just the ordinary consequences of choosing the wrong runtime for each half of the feature. That judgement is what the mid and senior bands in the Salesforce developer salary guide are actually pricing, and the deployment story around getting a change like this released is a separate question, covered in scratch orgs vs sandboxes.
Where Deployment Fits Instead
One boundary worth stating, because these questions blur together on real projects: this article is about where code runs. Whether that code reaches a shared environment through a scratch org or a sandbox, how source tracking behaves, and what your CI does on a pull request is an ALM question with a different answer — deliberately not repeated here, but covered in Salesforce DX: scratch orgs vs sandboxes.
Training Resources
Both halves, taught together, is how this judgement is actually learned. SkilBrill Salesforce Developer Training covers Apex, Lightning Web Components, governor limits, security patterns and real project work, with career support and placement assistance.
FAQ
What is the difference between Salesforce Apex and LWC?
Apex is server-side: it queries and writes data, enforces rules and runs under governor limits. LWC is the browser-side framework for UI behaviour, state and instant feedback, and it can only reach data through Apex or platform APIs.
When should I use Apex instead of LWC in Salesforce?
Whenever SOQL, DML, sharing, field-level security, external callouts or bulk processing is involved — and always for validation that must actually hold. If it needs no data and no enforced rule, keep it in the component.
Can LWC replace Apex entirely?
No. LWC cannot query or write data on its own; it depends on Apex or the UI API, and any logic that must be tamper-proof has to live server-side. Client-only logic is advisory by definition.
How many Apex calls should one LWC component make?
Ideally one per data need, batched. A call per row recreates the SOQL-in-a-loop problem with latency added — fetch the working set in a single wire call, then filter and sort locally.
Do I need JavaScript to be a Salesforce developer?
Yes, at least enough to read and write modern ES6 classes and handle events — LWC is standard JavaScript with a reactive framework on top, not Visualforce. Apex alone gets you through junior work; Apex plus LWC is the mid-band requirement.
