Salesforce, the company, runs a very different interview than the one you'll get for a Salesforce Developer role at, say, a 300-person insurance brokerage or a healthcare staffing firm that runs its entire pipeline on the platform. If you found your way here after reading a general "Salesforce interview questions" page, you might be prepping for the wrong loop. That page covers Salesforce hiring its own software engineers. This one covers Apex, SOQL, triggers, Lightning Web Components, and the declarative admin layer that almost every company running Salesforce tests, regardless of headcount.
The U.S. Bureau of Labor Statistics projects software developer employment to keep growing faster than almost any other occupation this decade, and a fair chunk of that growth sits inside the Salesforce ecosystem specifically, admins and developers maintaining the platform for the tens of thousands of companies running sales, service, or order management on it. Almost every one of those companies asks some version of the same opening question, worded differently every time: do you actually understand why Salesforce enforces governor limits, or did you just memorize the numbers?
The 51 questions below split across four areas: Apex language and async patterns, SOQL and SOSL, triggers and governor limits, and Lightning Web Components plus the declarative admin tools every developer eventually has to talk to in an interview. The governor limit numbers came straight from Salesforce's own Apex Developer Guide, not a secondhand blog post. A surprising number of the wrong answers I've seen candidates give trace back to limits guides that are a couple of API versions out of date.
Apex fundamentals, OOP, and asynchronous processing
Apex looks like Java with the edges filed down, and that resemblance is exactly the trap. These questions check whether you understand the places Apex actually diverges, not whether you can recite class syntax.
Easy questions
18A List keeps insertion order and allows duplicates. A Set holds only unique values with no guaranteed order. A Map stores key-value pairs, and it's what you reach for almost every time you need fast lookup by Id, which is the standard shape of a bulkified trigger handler matching incoming records against existing ones.
The follow-up interviewers actually care about: why not just loop through a List and compare fields every time? Because that's O(n) per lookup and O(n squared) across a whole trigger batch. A Map keyed by Id turns that into O(1) per lookup, which matters a lot more once you're processing 200 records instead of two.
For primitives, Integer, Boolean, Long, == compares value, same as Java. For Strings, Apex special-cases things: == compares value too, even though String is technically a class, so two separately built strings with identical content are always == equal. That's actually the opposite of Java's default, where new String("test") == "test" returns false.
For custom Apex classes and sObjects, == falls back to reference equality unless you override equals(). Two Account records pulled from separate queries with identical field values will not be == equal, only Id-equal if you compare the Id fields specifically. The habit interviewers want to see: never rely on == to check "are these the same record," compare Ids instead.
Static methods and variables belong to the class itself, not to any particular instance, and you call them without instantiating anything. Instance methods need an object to operate on. Trigger handler classes lean heavily on static methods because a trigger doesn't naturally want to instantiate an object per record; it wants one entry point that receives the whole batch.
Static variables carry one more property worth knowing cold: they persist for the life of a single transaction, well beyond a single method call. That's exactly the mechanism most recursion guards rely on, which comes up again later in the trigger section.
A class implementing Schedulable defines execute(SchedulableContext) and gets registered with a CRON expression via System.schedule(). Salesforce allows a maximum of 100 scheduled Apex jobs active at the same time per org, which sounds generous until an org's automation grows organically over a few years and nobody's cleaning up old scheduled jobs.
The common pattern worth knowing: a scheduled class is usually thin, its entire job is to kick off Database.executeBatch() so the heavy lifting happens in Batch Apex with its own governor limits, rather than trying to do real work directly inside the scheduled execute() method.
SOQL queries one known object (or its related objects) when you already know which object holds the data. SOSL does a full-text search across multiple objects simultaneously using FIND, useful for a global search bar where you don't know in advance whether the match is an Account, a Contact, or a Case.
A syntax detail worth having memorized: FIND {search term} IN ALL FIELDS RETURNING Account(Name, Industry), Contact(FirstName, LastName) searches across both objects in one call and returns typed result lists per object. SOQL simply can't do that in a single query; you'd need one query per object.
Trigger.new holds the new or updated record versions (writable in before triggers, read-only in after). Trigger.old holds the previous version, available in update and delete triggers. Trigger.newMap and Trigger.oldMap give you the same data keyed by Id for fast lookups. Boolean flags, Trigger.isInsert, isUpdate, isDelete, isBefore, isAfter, tell you which context you're in when a single trigger handles multiple DML operations.
The subtle one: Trigger.old and Trigger.oldMap don't exist on insert (there's no "old" version of a brand-new record), and Trigger.new doesn't exist on delete (there's nothing new about a record being removed). Referencing the wrong one for the context throws a null reference error that's confusing to debug if you don't already know why it's null.
Multiple triggers on the same object run in an undocumented, non-configurable order, which makes debugging execution order genuinely miserable once a second or third trigger gets added over time by different developers. The standard fix: exactly one trigger per object, as thin as possible, that immediately delegates to a handler class where the real logic lives, ordered explicitly and readably.
This is one of those Salesforce conventions that isn't enforced by the platform at all, nothing stops you from writing three separate triggers on Opportunity, but every experienced Salesforce Developer treats it as close to a hard rule anyway, because the alternative has genuinely bitten enough teams that the convention just stuck.
Down: the parent passes data into the child through @api-decorated public properties, set as attributes in the parent's template. Up: the child dispatches a CustomEvent, and the parent listens for it with an event handler bound in its own template. Data never flows sideways directly between sibling components; anything shared between siblings has to go through a common parent or a shared service.
This one-directional flow is deliberate, not a limitation Salesforce hasn't gotten around to fixing. It's the same unidirectional data flow model React and Vue both use, for the same reason: it makes state changes traceable instead of letting any component mutate shared state from anywhere.
LWC is built on native Web Components standards, uses actual shadow DOM for style and markup encapsulation, and ships as ES modules, which gives it a smaller bundle size and meaningfully better rendering performance than Aura's proprietary framework. For any greenfield component, LWC is the default and Salesforce's own guidance says so directly.
Aura still shows up in a handful of legacy contexts: certain older Lightning Out embeddings, a few Salesforce-provided base components that haven't been rebuilt in LWC, and orgs with a large existing Aura codebase where a full rewrite isn't worth the cost yet. Knowing that Aura and LWC components can coexist and even wrap each other, an LWC can be embedded inside an Aura component, but not cleanly the other direction, is usually the deeper answer interviewers are actually listening for.
A Profile is the baseline permission set every user must have exactly one of, historically the primary place object and field permissions lived. Permission Sets add incremental permissions on top of a profile without requiring a new profile per combination of access needs, which is the pattern Salesforce now recommends over building dozens of near-duplicate profiles. Permission Set Groups bundle multiple Permission Sets into one assignable unit, useful when a role genuinely needs five or six specific permission sets together as a package.
The direction Salesforce has been pushing for a few years now: minimal profiles that grant almost nothing beyond login access, with basically all real permissions layered on through Permission Sets and Groups instead. It's more setup work upfront and considerably easier to audit and adjust later.
Validation rules for anything expressible as a single formula evaluating fields on the record being saved, since they're declarative, don't require deployment through a code pipeline, and are exactly what Salesforce admins expect to find and maintain without a developer. Apex for anything that needs to check related records across objects, call an external system, or apply conditional logic too complex for a formula to stay readable.
A subtlety worth having ready: validation rules run in the "system validation" phase before before-triggers even fire, so if your before trigger modifies a field, the validation rule sees the modified value, not the originally submitted one. Getting that ordering backward is a common source of "why did my validation rule pass when I expected it to fail" confusion.
A Record Type controls which picklist values are available on a record and which business process (for Opportunities and Leads specifically) applies. A Page Layout controls which fields, related lists, and buttons appear on the record's detail and edit pages. The two combine per profile: the same object can show a completely different layout and different picklist values depending on which Record Type a given user's profile is assigned to use.
A concrete example that comes up constantly in interviews: a company selling both software and hardware might use two Opportunity Record Types with different sales stages and different layouts, letting one org support two genuinely different sales processes on the same underlying object without forking it into two custom objects.
No. The Salesforce interview questions page covers Salesforce the company hiring its own software engineers (MTS, SMTS), which tests core CS fundamentals, Java or Python, and system design, not Apex specifically. This page covers Salesforce Developer roles at any company running the platform, where Apex, SOQL, triggers, and Lightning Web Components are the actual bar you're tested against.
Administrator roles generally don't require writing Apex, and interviews for that track focus on declarative tools instead: Flow, validation rules, sharing and security model, reports and dashboards. Some overlap does exist, an admin who can read Apex well enough to know when a request actually needs a developer is genuinely valuable, but writing production Apex code isn't the bar for a pure admin role.
Both, honestly. The certification exam covers close to the same ground as this page, governor limits, triggers, SOQL, LWC basics, testing, so studying for it is reasonable interview prep even if you never take the actual exam. Whether it moves your resume forward depends heavily on the company; some treat it as a meaningful signal, others barely glance at it and care more about what you can explain live in the interview.
75% overall code coverage across the org, enforced automatically at deployment time, not a suggestion. Individual classes can sit below 75% as long as the org-wide average clears the bar, though Salesforce also expects every trigger to have some coverage specifically, not just overall percentage padded by unrelated classes.
Largely, yes, for any role posted in the last couple of years. LWC is Salesforce's current default for new UI work, and most interviewers weight questions accordingly. Visualforce and Aura still come up for roles maintaining an older, established org, so it's worth knowing the basics of both even if your prep time goes mostly toward LWC.
This varies more by company size than almost any other factor on this page, since "Salesforce Developer" gets hired by everyone from three-person consultancies to Fortune 500 IT departments. A typical range is two to four rounds: a recruiter or hiring manager screen, one technical round covering Apex/SOQL/triggers (sometimes with live coding, sometimes conversational), and occasionally a system design or architecture round for senior roles. Smaller consultancies often compress this to two rounds total.
Medium questions
29An abstract class can hold real implementation, constructor logic, and instance state, but a class can only extend one. An interface defines a contract with no implementation (Apex doesn't support default methods the way modern Java does), but a class can implement as many interfaces as it needs. Use an abstract class when subclasses genuinely share behavior. Use an interface when unrelated classes need to honor the same contract, Batchable, Schedulable, and Queueable are the three you'll use constantly without necessarily thinking of them as "just interfaces."
I'd argue Apex developers reach for interfaces more often than the equivalent Java crowd does, mostly because so much of the async framework (Database.Batchable, Database.Stateful, Schedulable) is interface-based by design. Knowing which interface maps to which async behavior is honestly more interview-relevant than the abstract-class-versus-interface theory itself.
Standard try-catch-finally, plus Apex ships built-in exception types: DmlException for failed DML, QueryException for a malformed or zero-row SOQL query used with an assignment that expects one row, NullPointerException for the usual reason. LimitException is the odd one out. It fires when you exceed a governor limit, and you cannot catch it. The transaction is done the moment it throws.
That's a deliberate design choice, not an oversight. If LimitException were catchable, code could silently swallow a governor limit breach and keep running in a broken state on shared infrastructure. Salesforce would rather kill the transaction cleanly than let a misbehaving org degrade performance for everyone else on the same instance.
"with sharing" enforces the running user's record-level sharing rules, so a query inside that class only returns records the user can actually see. "without sharing" ignores sharing rules entirely and runs with full object access (subject to CRUD and field-level security, which sharing keywords don't touch). "inherited sharing" is the one people forget: the class inherits whatever sharing mode the calling context used, and if there's no calling context at all, it defaults to with sharing.
The security mistake I see most often in real code review: a class marked "without sharing" for one legitimate reason (a scheduled batch job that needs to see every record) that then gets reused as a utility method somewhere a regular user calls it, quietly bypassing sharing rules the org actually depends on. Interviewers who ask this question are usually testing whether you'd catch that reuse risk, not just whether you can define the three keywords.
@future methods run asynchronously in their own transaction with a fresh set of governor limits, which makes them the standard fix for two problems: callouts you can't make in the same transaction as a pending DML operation, and heavy processing you want off the user's critical path. They must be static, must return void, and can only accept primitive or collection-of-primitive parameters, no sObjects, which trips people up constantly.
The real limitation shows up at scale: future methods can't be chained, and you can't monitor their job status the way you can with Queueable. For anything beyond a simple fire-and-forget callout, Queueable is the better answer, which is exactly the next question.
Queueable accepts complex, non-primitive types including sObjects and custom classes, since it's a class implementing an interface rather than a static method with restricted parameters. It supports job chaining (one Queueable enqueuing the next from within execute()), and it returns a job Id you can use to check status via AsyncApexJob. @future gives you none of that.
The honest trade-off: Queueable is slightly more ceremony to set up for a genuinely simple one-off callout, which is the one case where I'd still reach for @future over Queueable. For anything that needs to pass an sObject, chain a follow-up step, or be monitored, Queueable wins outright.
public class SyncAccountJob implements Queueable {
private List<Account> accounts;
public SyncAccountJob(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext context) {
// do the work, then optionally chain the next step
for (Account acc : accounts) {
acc.Last_Synced__c = System.now();
}
update accounts;
if (!Test.isRunningTest()) {
System.enqueueJob(new NotifyDownstreamJob(accounts));
}
}
}Database.Batchable requires three methods. start() returns the full set of records to process, typically via a Database.QueryLocator so you're not limited by the standard 50,000-row SOQL cap. execute() runs against chunks of that set, up to 2,000 records per batch by default, configurable down via the scope parameter passed to Database.executeBatch(). finish() runs once, after every chunk completes, usually for a summary email or a follow-up job.
The detail that actually answers "why does this matter": each execute() call gets its own fresh governor limits. That's the entire reason Batch Apex exists. A single synchronous transaction caps out at 100 SOQL queries and 10,000 milliseconds of CPU time; Batch Apex sidesteps that ceiling entirely by resetting the meter for every chunk, which is how you process millions of records without ever touching a limit that would kill a normal transaction.
public class RecalculateAccountScoreBatch implements Database.Batchable<sObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, AnnualRevenue, Industry FROM Account WHERE Score__c = null'
);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
acc.Score__c = ScoreCalculator.calculate(acc);
}
update scope;
}
public void finish(Database.BatchableContext bc) {
// one summary email, not one per chunk
}
}A plain insert statement is all-or-nothing. One bad record in a list of 500 rolls back every single one. Database.insert(myList, false) sets allOrNone to false, which allows partial success: valid records commit, invalid ones don't, and you get back a list of Database.SaveResult objects telling you exactly which index failed and why.
The part candidates usually skip: you have to actually inspect those SaveResult objects, checking isSuccess() and getErrors() per record, or the partial failure just disappears silently into an ignored return value. Partial success without error handling is arguably worse than an all-or-nothing failure, since at least the latter fails loudly.
Database.SaveResult[] results = Database.insert(newAccounts, false);
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
for (Database.Error err : results[i].getErrors()) {
System.debug('Row ' + i + ' failed: ' + err.getMessage());
}
}
}Salesforce requires a minimum of 75% Apex code coverage org-wide before you can deploy to production, enforced automatically at deployment time, not just as a best practice. @isTest-annotated classes run in a separate execution context with their own governor limits, and Test.startTest() / Test.stopTest() resets those limits mid-test, letting you test governor-limit-sensitive code without accidentally hitting the real limit inside the test itself.
The gap most candidates miss: 75% coverage measures lines executed, not correctness. A test that calls a method and asserts nothing still counts toward coverage. Interviewers who push on this want to hear that you assert on actual outcomes, field values, DML results, exception behavior, rather than resting on the fact that you ran the code once to satisfy the percentage.
Implement HttpCalloutMock, override respond() to return a synthetic HttpResponse, and register it with Test.setMock() before the code under test runs. Salesforce blocks real outbound callouts entirely inside a test context, so without a mock, any test touching a callout throws immediately.
The follow-up worth having a real answer for: what if the same test needs to simulate a failure response, a timeout, or a malformed JSON body? Multiple mock implementations, or one mock class with a configurable response injected through the constructor, is the expected pattern. A single hardcoded 200-OK mock only proves the happy path works, and interviewers know it.
Child-to-parent traversal uses dot notation: SELECT Name, Account.Name FROM Contact walks up from a Contact to its parent Account in the same query. Parent-to-child goes the other way with a nested subquery: SELECT Name, (SELECT LastName FROM Contacts) FROM Account pulls every related Contact for each Account row in a single round trip.
The limit worth knowing: parent-to-child subqueries only go one level of standard relationship by default without additional joins, and nesting too many subqueries inside one query burns through the query's overall complexity budget faster than most people expect, particularly on objects with a lot of child relationships defined.
Whether the filter is selective. Salesforce automatically indexes Id, Name, external Id fields, and CreatedDate/LastModifiedDate, along with any custom field you've explicitly marked as an index candidate through a case with Salesforce support. A WHERE clause on an unindexed field forces a full table scan, and on an object with millions of rows, that's the query timing out, not the query itself being "slow SOQL."
Leading wildcards (LIKE '%term') also defeat indexing entirely, since the database can't use an index to search for something that could start anywhere in the string. The fix is usually restructuring the filter to lead with an indexed field and treat the wildcard match as a secondary, smaller-scope filter.
Bind variables. Database.query('SELECT Id FROM Account WHERE Name = :accountName') passes accountName as a bound parameter rather than string-concatenating it into the query, which is the same fix as parameterized queries in any other language with a SQL-adjacent surface. When you genuinely can't use a bind variable, wrap any user-supplied value with String.escapeSingleQuotes() before concatenating it in.
The mistake that still shows up in production code review: building a dynamic SOQL string with '+' concatenation and user input straight from an LWC form, with no escaping and no bind variable. It's the exact same vulnerability class as classic SQL injection, just running on Salesforce's database layer instead of MySQL or Postgres.
// safe: bind variable
String acctName = userInput;
List<Account> accts = [SELECT Id FROM Account WHERE Name = :acctName];
// safe: escaped, when a bind variable isn't possible (e.g. building a dynamic ORDER BY)
String safeName = String.escapeSingleQuotes(userInput);
String query = 'SELECT Id FROM Account WHERE Name = '' + safeName + ''';
List<Account> results = Database.query(query);SELECT AccountId, COUNT(Id), SUM(Amount) FROM Opportunity GROUP BY AccountId returns a list of AggregateResult objects rather than sObjects, and you access each aggregate value by its alias or positional expression, get(0) style, not by dotted field access.
The limit worth knowing cold: aggregate queries returning more than 2,000 grouped rows need to run inside Batch Apex instead of a normal synchronous context, since the standard governor limit on aggregate result rows is lower than the plain SOQL row limit.
Field-level security and object-level CRUD permissions are not automatically checked by a plain SOQL query, even one running inside a "with sharing" class. WITH SECURITY_ENFORCED, appended to the end of a SOQL query, throws a SecurityException at query time if the running user lacks read access to any field or object referenced, instead of silently returning data the user shouldn't see.
This matters most on Apex exposed through @AuraEnabled methods to a Lightning component, since that's the surface where a user's actual permission set, not a system-context assumption, decides what they should see. The older pattern before SECURITY_ENFORCED existed was manually checking Schema.SObjectField.getDescribe().isAccessible() field by field, which works but is tedious and easy to forget on one field out of twelve.
A SOQL for-loop (for (Account acc : [SELECT Id FROM Account]) {... }) processes query results in batches of 200 without loading the entire result set into a single collection variable in heap memory at once. A synchronous transaction caps heap at 6 MB; loading a query returning hundreds of thousands of rows into one List
Use it specifically when you're iterating a query that could return a genuinely large result set and you don't need random access into the whole collection at once, just sequential processing per record.
A standard SOQL query is capped at 50,000 total records retrieved per transaction. Database.getQueryLocator(), used specifically inside a Batchable start() method, is exempt from that cap and can address up to 50 million records, since Batch Apex streams through the result set in chunks rather than materializing the whole thing in one transaction's heap.
The gotcha: getQueryLocator() only works for simple queries. Anything using GROUP BY, aggregate functions, or certain complex nested subqueries has to fall back to returning an Iterable
Governor limits are hard runtime ceilings on resource consumption per transaction, SOQL queries, DML statements, CPU time, heap size, and more, enforced because Salesforce runs every customer's org on shared, multi-tenant infrastructure. Without a hard cap, one org's inefficient code could consume enough shared compute or database capacity to degrade performance for every other org on the same instance.
The framing that separates a memorized answer from a real one: governor limits aren't Salesforce being stingy, they're the direct cost of getting fully managed, no-infrastructure-to-run infrastructure. You trade some control for never having to think about database connection pools or server capacity yourself. Every trigger, batch job, and Apex class you write operates inside that trade-off whether you name it explicitly or not.
Synchronous transactions get 100 SOQL queries, 150 DML statements, 10,000 milliseconds of CPU time, and a 6 MB heap. Asynchronous contexts (Batch, Queueable, future) get roughly double on most of those: 200 SOQL queries, 60,000 milliseconds of CPU time, and 12 MB of heap, though the 150 DML statement cap and the 10,000-row DML limit stay the same across both.
| Limit | Synchronous | Asynchronous |
|---|---|---|
| SOQL queries per transaction | 100 | 200 |
| DML statements per transaction | 150 | 150 |
| Total records via DML | 10,000 | 10,000 |
| CPU time | 10,000 ms | 60,000 ms |
| Heap size | 6 MB | 12 MB |
Interviewers rarely want the whole table recited from memory. They want to see you know the shape of the trade-off, async gets more room specifically because it's not blocking a user waiting on a page to load, and that you'd actually look these numbers up rather than guess under pressure in a real production incident.
Before triggers run before the record commits to the database, which makes them the right place for field validation and modifying fields on the record currently being processed, since you're changing Trigger.new directly with no extra DML call needed. After triggers run once the record is already committed, so they're for anything that needs the record's final Id or needs to touch related records, creating a child record, updating a different object, sending a notification.
Using an after trigger for something a before trigger could handle costs you an extra, avoidable DML statement (you'd have to explicitly update the record again since it already committed), which eats into the 150-statement governor limit for no real benefit.
Triggers receive batches of up to 200 records at once, not one record at a time, even when a user only edits a single row through the UI (data loads, API calls, and bulk updates routinely hit the full 200). Non-bulkified code, a SOQL query or DML statement written inside a for-loop over Trigger.new, runs that query or DML call once per record. At 200 records, that's 200 SOQL queries against a synchronous limit of 100, and the transaction dies partway through with a LimitException.
The bulkified pattern: collect the Ids you need from Trigger.new into a Set first, run one single SOQL query using that Set in a WHERE clause, put the results into a Map keyed by Id, then loop through Trigger.new again doing Map lookups instead of fresh queries. One query and one DML statement total, regardless of whether the trigger fires on 1 record or 200.
// not bulkified: one query per record, dies at 101 records
for (Opportunity opp : Trigger.new) {
Account acc = [SELECT Id, Industry FROM Account WHERE Id = :opp.AccountId];
opp.Industry_Snapshot__c = acc.Industry;
}
// bulkified: one query total, works for 1 record or 200
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
accountIds.add(opp.AccountId);
}
Map<Id, Account> accountsById = new Map<Id, Account>(
[SELECT Id, Industry FROM Account WHERE Id IN :accountIds]
);
for (Opportunity opp : Trigger.new) {
opp.Industry_Snapshot__c = accountsById.get(opp.AccountId).Industry;
}A static Boolean flag on a class, checked and set at the start of the logic that might trigger a recursive save. Static variables persist for the life of the transaction (not just one method call), so once the flag flips to true, any subsequent recursive entry into the same trigger context sees it's already running and skips the logic instead of re-executing it.
Where this actually bites people: forgetting that static variables reset per transaction rather than per trigger invocation, so a flag set true in one trigger's before-insert phase is still true when that same trigger fires again in after-insert within the same save. That's usually the intended behavior, but it's worth stating out loud that you understand why, rather than treating the static flag as a magic incantation.
public class OpportunityTriggerHandler {
private static Boolean isRunning = false;
public static void handleAfterUpdate(List<Opportunity> newOpps) {
if (isRunning) return;
isRunning = true;
// logic that updates related records, which could re-fire this trigger
updateRelatedAccounts(newOpps);
isRunning = false;
}
}Flow (specifically record-triggered Flow) handles most straightforward field updates, related record creation, and conditional branching declaratively, without deployable code, and Salesforce's own guidance leans toward Flow-first for anything Flow can actually express cleanly. Apex earns its keep for complex collection processing across multiple objects, conditional logic too tangled for Flow's visual model to stay readable, HTTP callouts, or bulk operations where Flow's per-record execution model becomes a real performance problem at scale.
The signal interviewers listen for: candidates who reach for Apex by default because it's what they're comfortable with, versus candidates who can articulate why a specific piece of logic genuinely needs code. "I'd probably start in Flow and only move to Apex once I hit something it can't cleanly do" is a stronger answer than either extreme.
Platform Events are a publish-subscribe messaging layer built on EventBus. A publisher fires an event and moves on immediately, and subscribers process it asynchronously and independently, in their own transaction with their own fresh governor limits, rather than everything happening synchronously inside the original triggering transaction.
The practical win: if updating an Opportunity needs to also notify three different downstream systems, and each notification path has its own callout or heavy processing, chaining all of that synchronously inside one trigger stacks every one of those costs against a single transaction's limits. Publishing one Platform Event and letting three separate subscribers pick it up independently spreads that cost across separate transactions instead, and a failure in one subscriber doesn't roll back the others.
constructor() fires first, before the component is inserted into the DOM, and shouldn't touch the DOM or component properties yet. connectedCallback() fires once the component is inserted into the DOM, and it's where data loading and subscriptions typically start. renderedCallback() fires after every render, including re-renders, so anything placed there needs its own guard against running repeatedly. disconnectedCallback() fires on removal, for cleanup. errorCallback() catches errors thrown by a child component specifically, not errors in the component's own code.
The mistake worth flagging: putting expensive data-fetching logic inside renderedCallback() without a guard condition. Since it fires after every single render, an unguarded fetch call there can trigger the same request dozens of times as the component re-renders for unrelated reasons.
@api marks a property as public, settable by a parent component. @wire connects a property or function to a reactive Salesforce data source (an Apex method, a UI API record, an object schema lookup) that automatically re-runs when its inputs change. @track historically forced reactivity on object and array properties specifically, since primitive properties were already reactive by default.
Since Spring '20, every class field is reactive by default in LWC, objects and arrays included, which made @track unnecessary for the vast majority of cases it used to be required for. It still technically exists and still works, but seeing it in a codebase built after 2020 is usually a sign of either an older tutorial the developer copied from, or genuinely deep nested mutation tracking that needs the explicit hint. Either way, if you see @track everywhere in a component built recently, ask why.
@wire is declarative and reactive: it automatically re-fetches when its reactive parameters change, and cacheable=true Apex methods benefit from client-side caching Salesforce manages for you. Imperative calls (calling an @AuraEnabled method directly as a regular JavaScript function, awaiting a Promise) are the right fit for anything conditional, anything user-triggered like a button click, or any mutation, since @wire is read-oriented and doesn't fit a "save this data" action well.
import getContacts from '@salesforce/apex/ContactController.getContacts';
import saveContact from '@salesforce/apex/ContactController.saveContact';
export default class ContactList extends LightningElement {
@wire(getContacts, { accountId: '$recordId' })
contacts;
async handleSaveClick(event) {
// imperative: this is a user-triggered mutation, not a passive data read
try {
await saveContact({ contact: this.newContact });
} catch (error) {
this.errorMessage = error.body.message;
}
}
}Organization-Wide Defaults (OWD) set the baseline record visibility for an object, Private, Public Read Only, or Public Read/Write, applied when nothing else grants broader access. Sharing rules then open up access beyond that baseline for specific groups: criteria-based sharing rules grant access when a record matches certain field values (all Opportunities with Region__c = 'EMEA' shared with the EMEA sales team), while owner-based rules grant access based on who owns the record (everything owned by Team A's role also visible to Team B).
The order matters conceptually even if it's not literally sequential code: OWD sets the floor, then role hierarchy, sharing rules, manual sharing, and Apex managed sharing each additively open up more access from there. Nothing can make access more restrictive than OWD from below; sharing only ever adds visibility, never removes it.
Salesforce stopped allowing new Process Builder and Workflow Rule creation and has pushed Flow as the single declarative automation tool going forward, mainly because running three separate automation engines on the same object with no unified, predictable execution order made debugging genuinely difficult, and Flow (specifically record-triggered Flow) can express everything the older two tools could plus considerably more.
Existing Process Builder and Workflow Rule automation keeps running; nothing forces an immediate migration. But interviewers, especially at companies with an older, established org, want to know you're aware of the migration path and wouldn't build new automation on a tool Salesforce has explicitly told customers to move away from.
Developer and Developer Pro sandboxes copy only metadata, no production data, and refresh on demand, good for individual feature work. Partial Copy sandboxes include a defined sample of production data. Full sandboxes mirror production data and storage limits entirely but can only refresh roughly once every 29 days, which is exactly why teams don't do all their day-to-day development directly in one.
Change Sets move metadata between orgs manually through the Setup UI, connection by connection, and hold up fine for smaller teams with infrequent releases. Salesforce DX, with source-driven development, scratch orgs, and unlocked or managed packages, is the direction larger teams have moved toward specifically because it supports real version control and CI/CD, something Change Sets were never built to do well. I'd genuinely hesitate to recommend Change Sets for a team of more than a handful of developers shipping regularly; the metadata drift between orgs becomes a real problem faster than people expect.
Hard questions
5Continuation lets a Visualforce or LWC-backed Apex controller make a long-running callout (up to 120 seconds) without tying up an Apex request thread for the entire wait. The controller returns the Continuation object, Salesforce releases the thread, and a separate callback method picks up execution once the response actually arrives.
It's a niche answer at most companies, and I'll admit most Salesforce Developer roles never touch it directly, since Lightning components more commonly just call an @AuraEnabled method that wraps a synchronous HTTP callout for anything under the normal timeout. It comes up specifically when an interviewer wants to know if you understand the difference between "async so governor limits reset" (Batch, Queueable, future) and "async so the request thread isn't blocked waiting on a slow external system" (Continuation). Those are two different problems that happen to both be called asynchronous.
Salesforce's query optimizer estimates the percentage of rows a filter will return before running the query, and a filter is considered selective if that estimate falls under roughly 10% of the table (the exact threshold scales with table size and index type). Selective filters can use an index; non-selective ones fall back to a full table scan regardless of whether an index exists on the field, since scanning the index itself would cost more than just reading the table when most rows match anyway.
The practical interview answer: combining two moderately selective filters with AND (Status = 'Open' AND CreatedDate = LAST_N_DAYS:7) is often selective even when neither filter alone would be, because the optimizer can evaluate the combined estimate. I don't fully trust my intuition on exactly where that threshold sits for every object shape, and honestly neither should you; when it's borderline, checking the actual query plan through the Query Plan tool in Setup beats guessing.
Roughly: system validation rules run first (required fields, field-level formatting), then before-save Flow, then before triggers, then internal system validations again, then custom validation rules, then duplicate rules, then the record actually saves to the database, then after-save Flow, then after triggers, then assignment rules, auto-response rules, workflow field updates (which can re-trigger the save process once), and finally sharing rule recalculation.
Most candidates can name "before triggers, then after triggers." Fewer can place validation rules, Flow, and workflow updates correctly relative to those two, and that ordering is exactly what determines whether a validation rule fires against a value your before trigger already changed, or against the original submitted value.
The practical reason to actually know this order: debugging a record that saves successfully but has a field value you didn't expect almost always comes down to something happening earlier or later in this sequence than you assumed.
UNABLE_TO_LOCK_ROW means two transactions tried to acquire a write lock on the same row (or the same parent row through a rollup or sharing recalculation) at the same time, and the database gave up waiting after its lock timeout instead of queuing indefinitely. It's not a governor limit, it's a row-level contention error from the underlying database, and it almost always shows up under concurrency you didn't have in your sandbox: multiple batch chunks, a Queueable and a trigger touching the same parent Account at once, or two integration calls updating the same record within milliseconds of each other.
The classic trigger is master-detail or roll-up summary fields. If ten child Opportunity updates fire in parallel batches, every one of them needs to lock the parent Account to recalculate rollups, so you get nine transactions queued behind one lock and some of them time out. The same thing happens with explicit sharing recalculation (a role hierarchy change kicking off async sharing jobs) colliding with a live DML on the same records, or with FOR UPDATE queries in Apex that lock rows you didn't need to touch.
First step in triage is checking which parent record ID keeps showing up in the failed job's error message, then figuring out what's serializing on it. Fixes depend on the cause: reduce the batch chunk size so fewer child updates hit the same parent concurrently, process by parent ID instead of arbitrary chunks so you're not splitting one parent's children across parallel batches, move contention-heavy updates into a single Queueable chain instead of firing them from multiple triggers, or add a retry with exponential backoff around the DML for transient collisions that would succeed on a second attempt. If the lock is coming from sharing recalculation, deferring or batching the sharing rule changes instead of triggering them mid-business-hours is usually the real fix, not the Apex code.
The number itself tells you what's wrong before you even open the code: 101 queries against a 100-query synchronous limit, on a scope of 200 records, means something in the execute() method is running roughly one query per record instead of one query total. Open the trigger or handler, find the SOQL statement sitting inside a for-loop over the scope, and that's almost always the entire bug.
The fix is the same bulkification pattern as the previous question: pull the query outside the loop, collect the needed Ids into a Set first, query once with an IN clause, then loop again doing Map lookups against the query results. I'd also add a quick sanity check afterward, count actual SOQL calls in a debug log for a test run of the full 200-record scope, since "I bulkified it" and "I verified it's actually down to one query" are not the same claim, and interviewers who've been burned by this in production know the difference.
Across mock interview sessions run through LastRoundAI, the failure pattern that shows up most with Salesforce Developer candidates isn't a gap in Apex syntax knowledge. It's candidates who can define governor limits correctly in isolation, then stall the moment a follow-up asks them to apply that reasoning to an unfamiliar scenario, a batch job hitting a CPU timeout instead of a SOQL limit, say. Reciting the numbers and reasoning from the numbers are different skills, and interviewers test the second one almost every time.
For anyone still shaky on why a specific limit exists rather than just what the number is, LastRoundAI's Concept Explainers break down exactly why multi-tenant architecture forces a hard SOQL cap, the way an interviewer would actually want it explained back to them, not just the number itself. And for the live interview, Interview Copilot feeds structured, sub-200ms guidance during the actual call across 50+ languages, invisible on screen share, for the moment a follow-up question lands and the room goes quiet.
LastRound data
What we see on our side
Of 1,393 sessions configured on LastRound between January 2025 and July 2026, 1,386 kept the default of five questions. Seven people changed it. Platform interviews rarely stop at five, so treat a five-question run as a warm-up rather than a rehearsal.
Frequently asked questions
What is tested most in Salesforce developer interviews?
Apex governor limits and the declarative-versus-code decision. Expect scenario questions about bulkification and when you would solve something with configuration instead of writing Apex at all.
How important is Lightning Web Component knowledge?
Important for most current roles. LWC has largely displaced Aura for new work, so expect questions on component communication and the wire service.
Do they ask about testing?
Yes, and it is a common weak point. Apex test coverage requirements and writing meaningful assertions rather than coverage-padding tests come up regularly.
Are certifications enough to pass?
They help you get the interview. The loop itself tends to probe scenarios that certifications do not cover, particularly around limits and data volume.
How this list was built
Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.
What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.
If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

