Apex Code Optimization: Performance Best Practices
Introduction
Slow Apex code doesn’t just frustrate users — it throws System.LimitException errors in production, burns through governor limits, and turns your platform into a bottleneck you’re constantly firefighting. If you’ve ever watched a trigger crawl through a bulk operation or seen a batch job blow past its CPU limit, you know the feeling.
The good news? Most Apex performance problems follow predictable patterns, and fixing them doesn’t require a full rewrite. Apex performance optimization is about applying the right technique in the right place — and this guide walks you through exactly that.
Table of Contents
- Performance Fundamentals
- Query Optimization
- Collection Efficiency
- Efficient Looping
- Reducing CPU Time
- Memory Management
- Asynchronous Processing
- Profiling and Monitoring
- Code Examples
- FAQ
- Optimization Checklist
Performance Fundamentals
What Affects Apex Performance?
Salesforce runs Apex inside a multi-tenant environment, which means governor limits aren’t arbitrary — they’re the guardrails that keep one org from hogging shared resources. Your code competes for CPU time, heap memory, and database I/O, all within strict limits per transaction.
The five biggest performance killers in Apex are:
- SOQL queries inside loops — each query counts against your 100 SOQL limit per transaction
- Inefficient collections — wrong data structure for the job causes unnecessary iterations
- Unselective queries — full table scans that ignore indexes and drag down database performance
- Synchronous processing of heavy workloads — logic that should run async but runs inline instead
- Memory bloat — querying more fields and records than you actually need
In 2026, Apex performance tuning has become even more critical as orgs push more logic server-side and use platform events, flows, and triggers in combination. A single poorly optimized trigger can cascade into CPU limit failures across complex automation chains.
The core principle: write code that does the minimum work necessary to produce the correct result. Everything else is noise.
Query Optimization
Selective Queries
A selective query is one where Salesforce can use an index to retrieve records efficiently, rather than scanning the entire table. Salesforce’s query optimizer kicks in automatically — but only if you give it something to work with.
A query is considered selective when its filter conditions match 10% or fewer records (or under 333,333 records on large objects). Queries on standard indexed fields like Id, Name, OwnerId, CreatedDate, and custom fields marked as External ID or Unique automatically benefit from index optimization.
Non-selective queries on large objects cause full table scans. On an object with millions of records, that’s a serious performance hit — and Salesforce may even block the query with a System.QueryException in some contexts.
Apex Code
// ❌ Non-selective — no indexed field in filter
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Industry = 'Technology'];
// ✅ Selective — filtering on an indexed field
List<Account> accounts = [SELECT Id, Name FROM Account WHERE OwnerId = :currentUserId AND Industry = 'Technology'];
Query Filters and Indexes
Custom indexes are one of the most underused tools in the Salesforce performance toolkit. You can request them from Salesforce Support for fields that appear frequently in WHERE clauses.
A few filtering best practices that directly improve apex efficiency:
- Put the most selective condition first in a compound WHERE clause
- Avoid functions on filter fields —
WHERE CALENDAR_YEAR(CreatedDate) = 2025prevents index use;WHERE CreatedDate >= 2025-01-01T00:00:00Zuses it - Avoid LIKE with a leading wildcard —
WHERE Name LIKE '%Corp'is non-selective;WHERE Name LIKE 'Corp%'can use an index - Use
LIMITwherever reasonable to cap query result size
Relationship Queries
Relationship queries (parent-to-child or child-to-parent) can save you multiple round trips to the database, but they have their own cost. Each level of nesting adds processing overhead.
Apex Code
// ✅ Single query fetching parent + child records
List<Account> accounts = [
SELECT Id, Name,
(SELECT Id, LastName, Email FROM Contacts WHERE IsActive__c = true)
FROM Account
WHERE OwnerId = :userId
LIMIT 200
];
Keep relationship queries to two levels deep where possible. Going three or four levels deep can make queries significantly slower and harder to debug. If you need deep hierarchies, consider breaking them into separate queries and joining in memory using Maps.

Read this Salesforce Documentation to Improve SOQL Query Performance.
Collection Efficiency
Choosing the Right Collection Type
Apex gives you three core collection types: List, Set, and Map. Picking the wrong one for the job adds unnecessary overhead — and the right choice is usually obvious once you know the use case.
| Collection | Best For | Lookup Performance |
|---|---|---|
List | Ordered data, iteration, index-based access | O(n) — linear scan |
Set | Uniqueness checks, deduplication | O(1) — constant time |
Map | Key-based lookups, grouping records by field | O(1) — constant time |
If you’re checking whether a value exists in a collection inside a loop, use a Set or Map — never a List. Using List.contains() in a loop is O(n²) complexity, which becomes painfully slow at scale.
Apex Code
// ❌ Slow — List.contains() inside a loop is O(n²)
List<String> existingEmails = new List<String>{'a@test.com', 'b@test.com'};
for (Lead lead : leads) {
if (existingEmails.contains(lead.Email)) { ... }
}
// ✅ Fast — Set.contains() is O(1)
Set<String> existingEmails = new Set<String>{'a@test.com', 'b@test.com'};
for (Lead lead : leads) {
if (existingEmails.contains(lead.Email)) { ... }
}
Collection Methods Performance
A few method-level habits that meaningfully improve apex performance:
- Pre-size your collections when you know the expected size:
new List<SObject>(records.size())avoids repeated memory reallocation - Use
Map.getOrDefault()or null-checkMap.get()rather than callingMap.containsKey()followed byMap.get()— that’s two lookups where one will do - Avoid
List.addAll()inside loops — build a temporary list and add once after the loop - Remove as you process only when necessary — unnecessary list mutation adds overhead

Read this article Apex Data Types and Collections
Efficient Looping
Avoiding Nested Loops
Nested loops are one of the most common causes of Apex hitting CPU time limits. A loop inside a loop means O(n × m) iterations — and in bulk operations with hundreds of records, that adds up fast.
The classic pattern to eliminate nested loops is building a Map outside the inner loop, then doing O(1) lookups inside the outer loop.
Apex Code
// ❌ Nested loop — O(n × m), hits CPU limits at scale
for (Opportunity opp : opportunities) {
for (Account acc : accounts) {
if (opp.AccountId == acc.Id) {
opp.AccountName__c = acc.Name;
}
}
}
// ✅ Map lookup — O(n + m), scales cleanly
Map<Id, Account> accountMap = new Map<Id, Account>(accounts);
for (Opportunity opp : opportunities) {
Account acc = accountMap.get(opp.AccountId);
if (acc != null) {
opp.AccountName__c = acc.Name;
}
}
Loop Optimization Techniques
Beyond eliminating nesting, there are several loop-level optimizations worth making a habit:
Move invariant calculations outside the loop. Anything that doesn’t change between iterations doesn’t belong inside the loop body.
Apex Code
// ❌ Recalculating outside data on every iteration
for (Contact c : contacts) {
String prefix = UserInfo.getName().substring(0, 3); // Runs N times
c.Description = prefix + c.LastName;
}
// ✅ Calculate once, use N times
String prefix = UserInfo.getName().substring(0, 3);
for (Contact c : contacts) {
c.Description = prefix + c.LastName;
}
Use enhanced for-loops over index-based loops unless you specifically need the index — they’re cleaner and slightly faster. Avoid string concatenation in loops — use List<String> and String.join() instead, since String is immutable and each + creates a new object.
Reducing CPU Time
Algorithm Complexity
Governor limits cap CPU time at 10,000ms per synchronous transaction (and 60,000ms asynchronous). When code is approaching that limit, the first place to look is algorithm complexity.
O(n²) algorithms are the usual culprit — sorting with bubble sort, nested loops for data matching, or repeated list scans. In most cases, you can drop to O(n log n) or O(n) by restructuring your data or using built-in collection methods.
A quick reference for common operations:
| Operation | Complexity | Notes |
|---|---|---|
Map.get() / Set.contains() | O(1) | Always prefer for lookups |
List.contains() | O(n) | Avoid inside loops |
List.sort() | O(n log n) | Built-in, efficient |
| Nested loops | O(n²) | Refactor to Map-based |
Avoiding Unnecessary Processing
Every line that runs inside a transaction costs CPU time. A few patterns to eliminate wasteful work:
- Guard clauses early — return or continue as soon as you know no work is needed, rather than checking conditions deep in nested code
- Don’t query fields you won’t use —
SELECT Id, Name, Emailinstead ofSELECT FIELDS(ALL)or an oversized field list - Avoid redundant DML — track what actually changed before issuing updates; use dirty flags on records
- Cache results of expensive calculations using a static variable when the same value is needed across multiple method calls in the same transaction
Apex Code
// Caching an expensive lookup across calls in the same transaction
private static Map<String, RecordType> recordTypeCache;
public static Id getRecordTypeId(String developerName) {
if (recordTypeCache == null) {
recordTypeCache = new Map<String, RecordType>();
for (RecordType rt : [SELECT Id, DeveloperName FROM RecordType WHERE SObjectType = 'Opportunity']) {
recordTypeCache.put(rt.DeveloperName, rt);
}
}
return recordTypeCache.get(developerName)?.Id;
}
Read this article about CPU Time Limit Exceeded in Apex.
Memory Management
Heap Size Considerations
Apex limits heap size to 6MB for synchronous transactions and 12MB for asynchronous. Heap bloat is usually caused by querying too many records, keeping large collections alive longer than needed, or storing the same data in multiple structures.
Practical heap management techniques:
- Query only the fields you need — every extra field on every record adds to heap consumption. On a query returning 10,000 records, even one unnecessary
LongTextAreafield can blow your heap limit. - Nullify large collections when done — setting a Map or List to
nullafter you’re finished with it makes those objects eligible for garbage collection - Avoid storing full SObject collections when IDs suffice — if downstream logic only needs record IDs, a
Set<Id>is far cheaper than aList<SObject> - Process records in chunks — in batch Apex, keep
scopesize (batch size) manageable; 200 is the default and usually optimal
Apex Code
// ❌ Querying all fields — heap-heavy
List<Account> accounts = [SELECT FIELDS(ALL) FROM Account WHERE ...];
// ✅ Query only what you need
List<Account> accounts = [SELECT Id, Name, Phone FROM Account WHERE ...];
// ✅ Nullify after use if the collection is large
accounts = null;
Asynchronous Processing
When to Offload Work
Synchronous Apex runs inline — the user (or platform) waits for it to complete. That’s fine for fast, user-facing operations. It’s not fine for heavy data processing, external callouts, or operations touching thousands of records.
Move work to async when any of these are true:
- The operation involves more than ~2,000 records
- You need to make HTTP callouts (required in async anyway)
- The work doesn’t need to complete before returning a response to the user
- You’re hitting CPU or heap limits in sync context
Salesforce gives you several async options, each with a different profile:
| Mechanism | Best For | Limit Profile |
|---|---|---|
@future | Simple async, callouts | 50 calls per transaction |
| Queueable Apex | Chained jobs, complex state | 50 enqueued per transaction |
| Batch Apex | Large data volumes (millions) | 5 concurrent batches |
| Scheduled Apex | Time-based recurring jobs | 100 scheduled jobs |
| Platform Events | Decoupled async processing | High throughput |
In my experience, Queueable is the most flexible option for most use cases — it supports passing complex objects, chaining jobs, and monitoring via AsyncApexJob. Use Batch Apex specifically when you’re processing volumes that exceed what a single async transaction can handle.
Read this article on Asynchronous Apex in Salesforce.
Profiling and Monitoring
Debug Logs
Debug logs are your first stop when hunting a performance issue. In Setup → Debug Logs, you can capture logs for a specific user and filter by log category and level.
For performance work, set:
- Apex Code →
FINEorFINER - Database →
FINE(to see SOQL details) - Profiling →
FINE(to see method-level timing)
Look for these patterns in the log:
- Long gaps between
SOQL_EXECUTE_BEGINandSOQL_EXECUTE_END→ slow queries - Repeated SOQL statements with identical patterns → SOQL inside a loop
- High
CUMULATIVE_LIMIT_USAGEnumbers → approaching limits
Developer Console Profiler
The Developer Console’s Performance tab gives you a visual breakdown of where CPU time is being spent. Run your code with “Open Log” enabled, then switch to the Performance tab to see a timeline view.
The timeline shows cumulative time per method call, making it easy to spot which methods are eating the most CPU. Look for:
- Methods called an unexpectedly high number of times (loop inefficiency)
- SOQL queries consuming a disproportionate share of execution time
- Repeated method calls that could be cached
For more complex performance analysis, Apex Replay Debugger in VS Code lets you step through a captured log as if it were a live debug session — without consuming additional transactions.
Code Examples
Here are a few complete, practical patterns that bring multiple optimization techniques together.
Bulkified Trigger Pattern
Apex Code
// ✅ Fully bulkified trigger handler
public class OpportunityTriggerHandler {
public static void handleBeforeUpdate(List<Opportunity> newOpps, Map<Id, Opportunity> oldMap) {
// 1. Collect IDs needed for queries — no SOQL yet
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : newOpps) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId);
}
}
// 2. Single bulk query outside the loop
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, SLA__c, Tier__c FROM Account WHERE Id IN :accountIds]
);
// 3. Process records using Map lookups — O(1) per record
for (Opportunity opp : newOpps) {
Opportunity oldOpp = oldMap.get(opp.Id);
Account acc = accountMap.get(opp.AccountId);
// Guard clause — skip if nothing relevant changed
if (opp.StageName == oldOpp.StageName || acc == null) continue;
// Apply business logic
if (opp.StageName == 'Closed Won' && acc.Tier__c == 'Platinum') {
opp.Priority__c = 'High';
}
}
}
}
Efficient Batch Apex
Apex Code
// ✅ Batch class optimized for memory and performance
public class AccountCleanupBatch implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
// Query only required fields
return Database.getQueryLocator(
'SELECT Id, Name, LastActivityDate FROM Account ' +
'WHERE LastActivityDate < LAST_N_DAYS:365 AND Type = \'Prospect\''
);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
List<Account> toUpdate = new List<Account>();
for (Account acc : scope) {
// Only update what needs updating
toUpdate.add(new Account(
Id = acc.Id,
Status__c = 'Inactive'
));
}
// Partial success — don't let one bad record kill the batch
Database.update(toUpdate, false);
}
public void finish(Database.BatchableContext bc) {}
}
FAQ
Q: What’s the most common cause of CPU limit exceptions in Apex? A: Nested loops combined with SOQL queries inside those loops. The fix is almost always the same: move queries outside the loop and use Maps for O(1) lookups inside it.
Q: How do I know if my SOQL query is selective? A: Check the Developer Console’s query plan tool (Help → Query Plan) or the query’s response time in debug logs. Non-selective queries on large objects also often appear in Salesforce’s performance alerts in Setup.
Q: When should I use Batch Apex vs Queueable Apex? A: Use Batch Apex when processing more than roughly 50,000 records or when you need Salesforce to manage chunking automatically. Use Queueable for complex async logic that doesn’t require that scale, especially when you need to pass state between jobs.
Q: Can I use caching in Apex to improve performance? A: Yes — static variables persist for the duration of a transaction and are excellent for caching expensive lookups like RecordType queries or Custom Metadata reads. For cross-transaction caching, consider Platform Cache (available in Enterprise and above).
Q: Does field-level security checking in SOQL impact performance? A: Yes, WITH USER_MODE or WITH SECURITY_ENFORCED add some overhead because Salesforce evaluates field-level and object-level permissions at query time. For performance-critical paths running in system context, be deliberate about where you apply security enforcement.
Q: How many SOQL queries can I run per Apex transaction? A: 100 SOQL queries for synchronous transactions, 200 for asynchronous. This is per-transaction across all code executing — including managed packages, flows, and triggers fired during the same transaction.
Q: What’s the best way to profile Apex performance in 2026? A: Start with Debug Logs and the Developer Console Performance tab for quick diagnosis. For deeper analysis, use Apex Replay Debugger in VS Code. For production monitoring, Salesforce’s Event Monitoring add-on gives you ApexExecution event logs with execution time, CPU time, and limit usage per transaction.
Conclusion
Apex performance optimization isn’t a one-time project — it’s a discipline you build into your development habits. The patterns that cause the most problems (SOQL in loops, nested collection scans, monolithic synchronous jobs) are preventable with the right instincts from the start.
The most important mindset shift: think in bulk from the very first line of any Apex you write. Salesforce was built for bulk operations. Code that treats a 200-record trigger invocation the same as a 1-record invocation is code that scales.