The Trailhead module on governor limits is accurate. It tells you the numbers: 100 SOQL queries per transaction, 50,000 records retrieved, 150 DML statements, 10 MB heap. What it doesn't tell you is how those limits interact in a system where a single business event touches 12 objects, triggers a chain of async jobs, and processes 80,000 records before the night batch finishes.
This post is about that reality.
The SOQL Limit: Where Most Large Orgs Actually Break
The 100 SOQL query limit is the most frequently hit limit in enterprise orgs, and the failure mode is almost always the same: queries inside loops. Everyone knows not to do it. It still happens constantly, because in large codebases the loop and the query are often 15 layers of abstraction apart.
The pattern that works at scale: a selector layer with explicit bulkification contracts.
public with sharing class AccountSelector {
// Contract: always takes a Set<Id>, always returns a Map.
// Callers never issue their own SOQL for Account — they use this.
public static Map<Id, Account> getByIds(Set<Id> ids) {
return new Map<Id, Account>([
SELECT Id, OwnerId, AccountNumber, BillingCountry, ExternalId__c
FROM Account
WHERE Id IN :ids
WITH USER_MODE
]);
}
// Separate method for each access pattern — never a generic "getAll"
public static List<Account> getActiveByTerritory(String territory) {
return [
SELECT Id, OwnerId, AccountNumber
FROM Account
WHERE Territory__c = :territory
AND Status__c = 'Active'
WITH USER_MODE
];
}
}
The second tool for SOQL pressure: catching it in CI before it hits production. Limits.getQueries() called at strategic checkpoints in your code tells you exactly where the budget is going. In test classes, assert on it:
@isTest
static void bulkTest_processTwoHundredAccounts() {
List<Account> accounts = TestFactory.createAccounts(200);
insert accounts;
Test.startTest();
AccountService.processOwnershipRules(accounts);
Test.stopTest();
// This is the assertion that catches regressions before production
System.assert(
Limits.getQueries() < 10,
'Expected < 10 SOQL queries, got: ' + Limits.getQueries()
);
}
A test that processes 200 records and asserts query count will fail the moment someone introduces a query in a loop downstream. That's the signal you want in CI, not at 3 AM in production.
Queueable vs Future vs Batch: The Actual Decision Framework
The common advice — "use Batch for bulk operations" — is incomplete. The better framework is: volume × dependency × retry requirements.
| Scenario | Use |
|---|---|
| Low volume (<200 records), fire-and-forget, no chaining | @future |
| Low-to-medium volume, needs chaining or state | Queueable |
| High volume (>10K records), stateless per chunk | Batch |
| High volume, complex state across chunks | Queueable chain |
The last row is the one that surprises people. When you have 500,000 records to process but each chunk needs to know the result of the previous one — aggregating error counts, managing a cursor, building a stateful sync log — Batch's stateless design fights you. You end up serializing state into a Custom Object on every execute() call and reading it back on the next. At that point, a Queueable chain with explicit state management is cleaner.
public with sharing class SyncChainQueueable implements Queueable {
private Id lastRecordId;
private Integer processedCount;
private Integer errorCount;
private static final Integer CHUNK_SIZE = 2000;
public SyncChainQueueable(Id lastRecordId, Integer processed, Integer errors) {
this.lastRecordId = lastRecordId;
this.processedCount = processed;
this.errorCount = errors;
}
public void execute(QueueableContext ctx) {
// Keyset (cursor) pagination: advance by Id instead of OFFSET
List<Account> chunk = (lastRecordId == null)
? [
SELECT Id, ExternalId__c, SyncStatus__c
FROM Account
WHERE SyncStatus__c = 'Pending'
ORDER BY Id
LIMIT :CHUNK_SIZE
]
: [
SELECT Id, ExternalId__c, SyncStatus__c
FROM Account
WHERE SyncStatus__c = 'Pending' AND Id > :lastRecordId
ORDER BY Id
LIMIT :CHUNK_SIZE
];
if (chunk.isEmpty()) {
SyncRunLog__c.finalize(processedCount, errorCount);
return;
}
Integer chunkErrors = AccountSyncService.process(chunk);
// Chain next queueable — cursor advances by the last Id in the chunk
if (!Test.isRunningTest()) {
System.enqueueJob(new SyncChainQueueable(
chunk[chunk.size() - 1].Id,
processedCount + chunk.size(),
errorCount + chunkErrors
));
}
}
}
Why a cursor and not OFFSET? The natural reflex is to paginate with LIMIT :CHUNK_SIZE OFFSET :offset, bumping the offset on every chunk. That breaks in production: SOQL caps OFFSET at 2,000 rows — the moment offset goes past 2000 the query throws NUMBER_OUTSIDE_VALID_RANGE, and on a 500K-record dataset you hit that ceiling by the second chunk. OFFSET also makes the database scan and discard every prior row on each page, getting slower the deeper you go. Keyset (cursor) pagination fixes both: order by Id, remember the last Id you processed, and filter Id > :lastRecordId on the next chunk — no row ceiling and constant cost per page.
The other limit to watch: Salesforce allows up to 50 chained Queueable jobs in a single transaction context. For very large datasets, you may still need Batch to avoid hitting the chain depth limit.
Heap Limit: Where Architects Consistently Underestimate
The 10 MB heap limit sounds generous until you understand what consumes it silently.
Queueable state serialization is the most common culprit. When you pass data between Queueable instances via instance variables, Salesforce serializes the entire object graph to store it between executions. A List<SObject> with 5,000 records × 30 fields × average 50 bytes per field value = ~7.5 MB of heap just for the state payload. Add the working set inside execute() and you're over the limit.
The fix: never pass records between Queueables. Pass IDs, re-query inside execute().
// Bad: serializes entire record set into Queueable state
public class BadQueueable implements Queueable {
private List<Account> accountsToProcess; // this gets serialized
public BadQueueable(List<Account> accounts) {
this.accountsToProcess = accounts; // ~7 MB already consumed here
}
}
// Correct: pass IDs, query fresh inside execute
public class GoodQueueable implements Queueable {
private Set<Id> accountIds; // a Set<Id> for 5000 records ≈ 200 KB
public GoodQueueable(Set<Id> ids) {
this.accountIds = ids;
}
public void execute(QueueableContext ctx) {
List<Account> accounts = [
SELECT Id, ExternalId__c, SyncStatus__c, OwnerId
FROM Account
WHERE Id IN :accountIds
];
// process accounts
}
}
JSON roundtrips are the second trap. JSON.serialize() + JSON.deserialize() on a large SObject list allocates memory for the original object, the serialized string, and the deserialized copy simultaneously. On a list of 10,000 records, this can briefly consume 3× the steady-state heap. Profile it with System.debug(Limits.getHeapSize()) before and after serialization calls.
CPU Time: The Limit That Only Shows Under Production Load
CPU time limit (10,000 ms synchronous) is the one that looks fine in sandbox and explodes in production. Why? Because sandbox orgs don't have the same trigger density. In production:
- Your trigger fires
- The managed package trigger fires
- A Flow runs on the same record
- A Process Builder (legacy) also fires
- A Platform Event gets published inside the transaction
- Another Flow runs as the PE subscriber
Each of these consumes CPU time within the same 10,000 ms budget. None of them can see each other's consumption. The transaction fails not because any single component is slow, but because the aggregate of five "fast" components exceeds the budget.
The diagnostic approach: System.debug(Limits.getCpuTime()) at the start and end of each logical section. In a complex org, you want this in your trigger handlers during profiling runs. The output tells you which component is consuming the budget disproportionately.
The architectural response: move anything non-critical to async. If the CPU pressure comes from a trigger doing work that doesn't need to happen synchronously, defer it to a Queueable. The Queueable gets its own 60,000 ms async CPU budget.
Platform Events Inside Transactions: The Limit Nobody Plans For
Two limits govern Platform Events published from Apex:
- Per-transaction publish limit: 150
EventBus.publish()calls per transaction (same limit as DML statements). - Event bus daily allocation: varies by edition, typically 250,000 events/day for Enterprise edition.
The per-transaction limit is the one that catches architects off guard. If your trigger publishes one event per record, and the trigger fires on a batch of 200 records, you've hit 200 publish calls — exceeding the 150-statement DML limit.
The fix: aggregate events before publishing.
public with sharing class AccountTriggerHandler extends TriggerHandler {
public override void afterUpdate() {
List<Account_Synced__e> events = new List<Account_Synced__e>();
for (Account acc : (List<Account>) Trigger.new) {
Account old = (Account) Trigger.oldMap.get(acc.Id);
if (acc.ExternalSyncStatus__c != old.ExternalSyncStatus__c) {
events.add(new Account_Synced__e(
AccountId__c = acc.Id,
NewStatus__c = acc.ExternalSyncStatus__c,
Timestamp__c = System.now()
));
}
}
if (!events.isEmpty()) {
List<Database.SaveResult> results = EventBus.publish(events);
// EventBus.publish() counts as ONE DML operation regardless of list size
// but the 150-publish-call limit still applies to the number of publish() calls
}
}
}
Calling EventBus.publish(list) with a list of 200 events counts as one publish call, not 200. The list approach is how you stay within limits when publishing at scale.
Designing a Nightly Sync for 10M Records: Real Trade-offs
The theoretical pattern for a 10M record nightly sync:
- Batch Apex, 2,000-record chunks → 5,000 execute() calls
- Each chunk calls an external API with a callout
- Results written back to Salesforce via DML
The problems that appear at scale:
- Callouts from Batch: each execute() can make callouts, but the Batch itself is bound by the standard callout limits per execute() context. If the external API has rate limits, Batch doesn't know — it will hammer the API and fail.
- Daily API call limits: 10M records / 2,000 per batch = 5,000 Batch execute() calls. If each calls an external API, that's 5,000 outbound callouts. Most external systems don't support this. The correct design: batch-insert IDs into a staging object, then process the staging object with bulk API calls that send thousands of IDs per request.
- The scope of "within limits": heap (2,000 records × field count), SOQL (selector layer, not inline), DML (one update per chunk, not per record), CPU (no complex logic per record — vectorize operations on the list).
The honest answer: a 10M record sync that stays within all limits simultaneously requires designing the external system's API alongside the Salesforce architecture. A per-record REST call to an external system is structurally incompatible with bulk processing in Apex. If the external API only supports per-record requests, the sync needs a middleware layer (MuleSoft, a Lambda, or Heroku) that aggregates Salesforce's bulk output into individually-limited calls.
Practical Takeaways
- If you don't have SOQL limit assertions in your test classes → add them to your 10 most critical services this sprint; they'll catch regressions immediately
- If you're choosing Batch for a stateful sync → evaluate Queueable chains first; the serialization complexity of stateful Batch is usually worse
- If you're passing SObject lists between Queueable instances → switch to passing IDs; heap exhaustion in Queueable state is silent until production
- If you're publishing Platform Events per-record → use a list and call
EventBus.publish(list)once per trigger context - If a 10M record sync is on your roadmap → the external API design is part of the Salesforce architecture; design them together
Second post in the "Apex for Enterprise Orgs" series. Next: SOQL at scale — selectivity math, LDV patterns, and what the query optimizer is actually doing.
Questions about limit strategies in your org? Find me on LinkedIn.