- Why governor limits exist and what they are actually protecting against
- How heap memory is consumed — what counts, what doesn't, and how to reduce it
- CPU time measurement: what the clock counts and where most CPU time is spent
- SOQL and DML limits: the bulkification principle and why it prevents most limit exceptions
- Asynchronous Apex limits and why async is not a magic limit escape hatch
- Architectural patterns for limit-safe code at scale
Why Limits Exist: The Multi-Tenant Contract
Governor limits are the enforcement mechanism for the multi-tenant resource sharing agreement. On a shared platform serving ~150,000 organisations on common infrastructure, any single tenant's runaway code — an infinite loop, a query that returns millions of rows, an in-memory object tree that consumes gigabytes — would degrade service for all other tenants on the same server instance.
The platform enforces limits per transaction. Each Apex transaction starts with a clean slate of allowances and is terminated — with a runtime exception — if it exceeds any limit before completing. This is not a penalty; it is a circuit breaker. The transaction fails fast, allowing the shared infrastructure to serve other tenants, rather than running to completion at the expense of every other org on the instance.
Understanding limits through this lens changes how you design code. The question shifts from "how do I avoid hitting limits?" to "how do I design so that my operations naturally fit within transaction boundaries?" That shift produces architecturally sound solutions rather than band-aid workarounds.
Heap Size: What Counts and How It Fills
The heap limit (6MB synchronous, 12MB async) measures the total memory occupied by all Apex objects in the current transaction at any given point. This includes every variable declared, every SObject queried into a list, every string concatenated, every collection built.
The most common heap consumption offenders are large query results stored in memory. A query returning 10,000 Opportunity records with 50 fields each — even before processing — can approach or exceed the heap limit. The fix is not to query fewer records (you may legitimately need 10,000) but to manage what you hold in memory simultaneously.
The Database.QueryLocator pattern in batch Apex is the architectural solution for processing large data volumes without heap exhaustion. A QueryLocator does not load all records into heap at once — it provides a cursor that loads records in the defined batch size (up to 2,000 per execute method call). The heap at any point contains only the current batch, not the entire result set.
// Anti-pattern: loading 50,000 records into heap
List<Opportunity> allOpps = [SELECT Id, Name, Amount, StageName,
CloseDate, AccountId, OwnerId, ... FROM Opportunity LIMIT 50000];
// At 50 fields × 50,000 records, this can easily exceed 6MB heap
// Correct pattern: Batch Apex with QueryLocator
global class OpportunityProcessor implements Database.Batchable<SObject> {
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Name, Amount, StageName FROM Opportunity'
);
// QueryLocator cursor — records not yet in heap
}
global void execute(Database.BatchableContext bc,
List<Opportunity> scope) {
// Only current batch (up to 2,000 records) is in heap here
for (Opportunity opp : scope) {
// process individual record
}
}
global void finish(Database.BatchableContext bc) { }
}
String manipulation is a hidden heap consumer. String concatenation in a loop (str += newPart) creates a new String object for every concatenation, leaving the old strings in heap until garbage collected. Use List
CPU Time: What the Clock Actually Measures
The CPU time limit (10,000ms synchronous, 60,000ms async) measures the time spent executing Apex code on the CPU — not wall clock time. Network I/O (waiting for a callout to return), database wait time (waiting for SOQL to execute), and time waiting for other Salesforce services do not count against the CPU limit. Only actual CPU execution counts.
This distinction matters for diagnosing limit exceptions. A transaction that seems to take 30 seconds wall clock time might only consume 8,000ms of CPU time — the rest is database and network wait. Conversely, a transaction that completes in 2 seconds wall clock time might consume 9,500ms of CPU time if it is doing intensive in-memory computation (sorting, string matching, complex calculation loops).
The highest CPU consumers in typical Apex code are nested loops over collections, regular expression matching on large strings, recursive method calls with deep stacks, and deserialising large JSON payloads. None of these involve I/O — they are pure computation that burns CPU time quickly.
// Anti-pattern: O(n²) nested loop
for (Contact con : contacts) {
for (Account acc : accounts) {
if (con.AccountId == acc.Id) {
// match found — process
}
}
}
// 1,000 contacts × 1,000 accounts = 1,000,000 iterations
// Correct pattern: Map-based O(n) lookup
Map<Id, Account> accMap = new Map<Id, Account>(accounts);
for (Contact con : contacts) {
Account acc = accMap.get(con.AccountId);
if (acc != null) {
// match found — process
}
}
// 1,000 contacts × 1 map lookup = 1,000 total operations
SOQL and DML: The Bulkification Principle
The SOQL limit (100 queries synchronous) and DML limit (150 statements synchronous) are designed to enforce bulkification — the practice of performing operations on collections rather than one record at a time. This is the most frequently violated architectural principle in Salesforce code.
The classic violation is the "query in a loop" pattern: for each record in a trigger, execute a SOQL query to get related data. A trigger processing 200 records would need 200 SOQL queries — double the limit. The platform will throw a LimitException on the 101st query.
Bulkified code solves this by collecting all IDs first, making one query for all related records, and then using an in-memory Map to associate results with trigger records. This reduces 200 SOQL queries to 1.
// Anti-pattern: SOQL in a loop (trigger context)
for (Order__c order : Trigger.new) {
// This is a new SOQL query for every record!
Account acc = [SELECT Id, Name, Credit_Limit__c
FROM Account WHERE Id = :order.Account__c];
if (order.Total_Amount__c > acc.Credit_Limit__c) {
order.addError('Order exceeds credit limit');
}
}
// Correct pattern: Bulk query, Map lookup
Set<Id> accountIds = new Set<Id>();
for (Order__c order : Trigger.new) {
accountIds.add(order.Account__c);
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, Credit_Limit__c FROM Account WHERE Id IN :accountIds]
);
for (Order__c order : Trigger.new) {
Account acc = accountMap.get(order.Account__c);
if (acc != null && order.Total_Amount__c > acc.Credit_Limit__c) {
order.addError('Order exceeds credit limit');
}
}
Asynchronous Apex: Higher Limits, Same Principles
Asynchronous Apex (Batch, Queueable, Scheduled, Future) runs with higher limits: 12MB heap, 60,000ms CPU, and for Batch specifically, 200 SOQL per execute method and 200 DML statements. This makes async Apex appear to solve limit problems — and for genuinely large data volumes it does. But async is not a magic escape hatch from good architectural practices.
Queueable Apex, the most flexible async pattern, runs with the same SOQL and DML limits as synchronous Apex (100 SOQL, 150 DML) — not the elevated Batch limits. The higher heap and CPU limits apply, but the query and DML limits are not elevated. Many developers assume Queueable inherits Batch's elevated query limits; it does not.
Future methods have the most constrained async execution: they cannot accept SObject parameters (only primitive types and collections of primitives), they cannot be called from Batch Apex, and they execute in an unordered queue with no dependency management. Queueable Apex supersedes future methods for most scenarios — use future only when the Apex must make callouts from a trigger context (where Queueable is blocked if there are pending trigger operations).
Limit Monitoring and Architectural Governance
Limit exceptions that reach production represent architectural failures that should have been caught earlier. The practices that prevent them span design, review, testing, and monitoring.
Limits API in Apex: The Limits class exposes the current usage and maximum for every governor limit in real time. Use it during development and testing to profile code behaviour, and in production monitoring to detect transactions approaching limits before they fail.
// Proactive limit monitoring in Apex
System.debug('SOQL queries used: ' + Limits.getQueries() +
' / ' + Limits.getLimitQueries());
System.debug('CPU time used: ' + Limits.getCpuTime() +
' ms / ' + Limits.getLimitCpuTime() + ' ms');
System.debug('Heap size: ' + Limits.getHeapSize() +
' bytes / ' + Limits.getLimitHeapSize() + ' bytes');
// Use in defensive patterns to branch to async before limit breach:
if (Limits.getQueries() > 80) {
// Approaching SOQL limit — offload remaining work to Queueable
System.enqueueJob(new RemainingWorkQueueable(remainingIds));
return;
}
Code review gates: SOQL in loops, DML in loops, and string concatenation in loops should be flagged as blocking issues in code review. These are not style preferences — they are correctness issues that will cause production failures at scale.
Test with volume: Unit tests with 1-2 records do not expose limit issues. Load tests that exercise triggers with 200 records in a batch, process flows with 10,000 record datasets, and batch jobs with the full production record volume are the only reliable way to discover limit bottlenecks before users do.
Key Takeaways
- Governor limits enforce the multi-tenant resource sharing contract — each transaction starts with a clean allowance, and the platform terminates any transaction that exceeds its limits to protect all tenants on shared infrastructure.
- Heap exhaustion most commonly comes from loading large query result sets into memory at once. Use Batch Apex QueryLocators for large data volumes — they load only the current batch into heap, not the full result set.
- CPU limit measures actual computation time, not wall clock time. Nested loops over large collections, JSON deserialisation, and regex matching are the primary CPU consumers — replace nested loops with Map-based O(n) lookups.
- Bulkification — querying and DML operating on collections, never on individual records in loops — is the solution to SOQL and DML limit violations. One query for N records beats N queries for 1 record each, always.
- Queueable Apex has the same SOQL and DML limits as synchronous Apex (100/150) — only Batch Apex has elevated query limits. Async does not universally escape limits.
- Use the Limits class to monitor real-time limit consumption in code, write tests with production-representative data volumes, and treat SOQL-in-loops as a blocking code review issue.
Test Your Understanding
1. An Apex trigger on the Contact object queries the parent Account inside a for loop iterating over Trigger.new. The trigger is called with a data load of 200 contacts. What happens?
2. A developer argues that wrapping a problematic trigger in a Queueable job will give it the elevated 200-SOQL limit of Batch Apex. Is this correct?
3. A transaction is taking 25 seconds wall clock time to complete but finishes before the CPU limit exception. Which part of the 25 seconds is NOT counted against the CPU time limit?
Discussion & Feedback