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

  1. Performance Fundamentals
  2. Query Optimization
  3. Collection Efficiency
  4. Efficient Looping
  5. Reducing CPU Time
  6. Memory Management
  7. Asynchronous Processing
  8. Profiling and Monitoring
  9. Code Examples
  10. FAQ
  11. 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:

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 fieldsWHERE CALENDAR_YEAR(CreatedDate) = 2025 prevents index use; WHERE CreatedDate >= 2025-01-01T00:00:00Z uses it
  • Avoid LIKE with a leading wildcardWHERE Name LIKE '%Corp' is non-selective; WHERE Name LIKE 'Corp%' can use an index
  • Use LIMIT wherever 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.

salesforce

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.

CollectionBest ForLookup Performance
ListOrdered data, iteration, index-based accessO(n) — linear scan
SetUniqueness checks, deduplicationO(1) — constant time
MapKey-based lookups, grouping records by fieldO(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-check Map.get() rather than calling Map.containsKey() followed by Map.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
sfdcrocks

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:

OperationComplexityNotes
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 loopsO(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 useSELECT Id, Name, Email instead of SELECT 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 LongTextArea field can blow your heap limit.
  • Nullify large collections when done — setting a Map or List to null after 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 a List<SObject>
  • Process records in chunks — in batch Apex, keep scope size (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:

MechanismBest ForLimit Profile
@futureSimple async, callouts50 calls per transaction
Queueable ApexChained jobs, complex state50 enqueued per transaction
Batch ApexLarge data volumes (millions)5 concurrent batches
Scheduled ApexTime-based recurring jobs100 scheduled jobs
Platform EventsDecoupled async processingHigh 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 CodeFINE or FINER
  • DatabaseFINE (to see SOQL details)
  • ProfilingFINE (to see method-level timing)

Look for these patterns in the log:

  • Long gaps between SOQL_EXECUTE_BEGIN and SOQL_EXECUTE_END → slow queries
  • Repeated SOQL statements with identical patterns → SOQL inside a loop
  • High CUMULATIVE_LIMIT_USAGE numbers → 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.

Add a Comment

Your email address will not be published. Required fields are marked *