The Story: NovaPay's Growing Pains
NovaPay is a Series B fintech startup processing payments for 12,000 small businesses. Their Salesforce org serves as the central ledger, tracking every transaction from multiple payment processors—Stripe, Square, PayPal, and direct ACH feeds.
The Numbers:
- 2.3 million transactions per month
- 4 payment processor integrations
- 72-hour reconciliation SLA for merchant payouts
- $847M monthly transaction volume
The Crisis: Their nightly reconciliation batch job started failing. At 1.8M records, it worked. At 2.1M, it timed out. At 2.3M, it crashed the org's async job queue entirely, blocking other critical processes.
System.AsyncException: Maximum number of batch apex jobs in queue (5) has been reachedThe finance team was manually reconciling transactions in spreadsheets. Merchants were calling about delayed payouts. The VP of Engineering called an all-hands.
The Solution: Apex Cursors with Queueable chaining—processing 2.3M transactions in 47 minutes with zero queue contention.
This is that implementation.
Section 1: Why Cursors Beat Batch for Financial Processing
The Fintech Constraint Matrix
| Requirement | Batch Apex Problem | Cursor Solution |
|---|---|---|
| Audit Trail | State lost on failure | Position persisted per-chunk |
| SLA Compliance | Queue delays unpredictable | Immediate execution, no queue |
| Partial Failures | Entire job restarts | Resume from exact record |
| Concurrent Jobs | 5-job limit blocks payroll, reporting | Unlimited queueable chains |
| Real-time Monitoring | Only start/finish events | Progress tracking per chunk |
Financial Data Processing Patterns
Traditional Batch Flow (Problematic):
[2.3M Records] → Batch Start → ... 6 hours ... → Success/Fail (binary)
↓
Holds 1 of 5 batch slots
No visibility for 6 hours
Failure = restart from zeroCursor + Queueable Flow (Optimal):
[2.3M Records] → Cursor Created → Queueable 1 (records 0-18K)
→ Queueable 2 (records 18K-36K)
→ Queueable 3 (records 36K-54K)
→ ... continues ...
Each queueable: ~30 seconds
Total time: ~47 minutes
Progress visible in real-time
Failure at record 1.2M? Resume from 1.2MCritical Architecture Note: Cursor Lifecycle
Cursors are NOT serializable across transactions. When chaining Queueables:
- The cursor object cannot be passed between jobs
- Each Queueable must recreate the cursor from the original query
- Use
ORDER BYto ensure deterministic record ordering for position-based resumption - Cursors expire after 15 minutes of inactivity
// WRONG - Cursor won't survive transaction boundary
System.enqueueJob(new MyJob(existingCursor, position));
// CORRECT - Recreate cursor, resume from tracked position
System.enqueueJob(new MyJob(queryString, position));Continue reading the full tutorial for the complete implementation including data model, reconciliation engine, finalizers, governor limits guide, and testing strategy...