Custom trigger on Opportunity sends duplicate email alerts when stage changes

After deploying a custom Apex trigger for Opportunity stage changes, users are receiving duplicate email alerts. The trigger fires on after update and sends notifications when StageName changes to ‘Closed Won’. Problem appeared after we added bulk update capability to our Lightning component.


trigger OpportunityStageAlert on Opportunity (after update) {
    for(Opportunity opp : Trigger.new) {
        if(opp.StageName == 'Closed Won') {
            EmailHelper.sendAlert(opp.Id);
        }
    }
}

Users report getting 2-3 identical emails per opportunity close. Sales team is frustrated with the noise. What’s causing this trigger recursion issue?

Here’s the complete fix addressing all three focus areas - trigger recursion prevention, stage change detection, and bulk update handling:

1. TRIGGER RECURSION PREVENTION - Add static set to track processed records:


public class TriggerHelper {
    private static Set<Id> processedOpps = new Set<Id>();

    public static Boolean isProcessed(Id oppId) {
        return processedOpps.contains(oppId);
    }

    public static void setProcessed(Id oppId) {
        processedOpps.add(oppId);
    }
}

This static set persists for the entire transaction, preventing the same opportunity from being processed multiple times even if the trigger fires again.

2. STAGE CHANGE DETECTION - Compare old vs new values:


trigger OpportunityStageAlert on Opportunity (after update) {
    List<Id> oppsToNotify = new List<Id>();

    for(Opportunity opp : Trigger.new) {
        Opportunity oldOpp = Trigger.oldMap.get(opp.Id);

        // Check if stage CHANGED to Closed Won
        if(opp.StageName == 'Closed Won' &&
           oldOpp.StageName != 'Closed Won' &&
           !TriggerHelper.isProcessed(opp.Id)) {
            oppsToNotify.add(opp.Id);
            TriggerHelper.setProcessed(opp.Id);
        }
    }
}

This ensures emails only send when stage transitions TO Closed Won, not on subsequent updates to already-closed opportunities.

3. BULK UPDATE HANDLING - Process all records in single operation:

Your original code called EmailHelper.sendAlert() inside the loop, making individual callouts/operations. For 50 opportunities, that’s 50 separate operations. Instead:


// After the loop, send all at once
if(!oppsToNotify.isEmpty()) {
    EmailHelper.sendBulkAlerts(oppsToNotify);
}

Implement sendBulkAlerts() to handle all notifications in one operation. This is critical for governor limits and performance.

Additional safeguards:

  • Move any DML operations out of the email helper to prevent trigger re-entry
  • If you need to update the opportunity after sending email, use an @future method or Platform Event to break the transaction chain
  • Add trigger framework to centralize recursion control across all triggers
  • Consider using a trigger handler pattern for better testability

Testing bulk scenarios:


// Pseudocode - Test class approach:
1. Create 200 test opportunities in various stages
2. Update all to Closed Won in single DML statement
3. Assert exactly 200 emails queued (not 400 or 600)
4. Verify no SOQL/DML governor limit exceptions
5. Update same opportunities again (change Amount)
6. Assert NO additional emails sent

This comprehensive approach eliminates duplicates while maintaining bulk operation efficiency. The key insight is that trigger recursion happens when you don’t track what you’ve already processed AND when your helper methods cause additional DML that re-fires the trigger. Breaking both cycles solves the problem permanently.


This draft is based on general Salesforce knowledge. It has not been verified against your specific version and environment. Practitioners: verify the steps and share your experience below.

Your trigger isn’t checking if StageName actually changed - it fires on ANY update to a Closed Won opportunity. You need to compare Trigger.old and Trigger.new to detect actual stage transitions. Also, your EmailHelper might be updating the same opportunity, causing re-entry.

Classic recursion problem. You’re missing stage change detection and probably have no recursion prevention. When bulk updates happen (like from your Lightning component), if any field updates occur after the email sends, the trigger fires again for the same records. Implement a static set to track processed opportunity IDs within the transaction.

That makes sense about not checking the old value. How do I properly detect stage changes in a bulk-safe way? Our Lightning component can update 50+ opportunities at once during quarterly reviews.

For bulk operations, you need to build a map of old values and compare. Loop through Trigger.old first to capture previous stages, then in your main loop check if the stage actually changed from something else to Closed Won. This prevents firing on updates to already-closed opportunities.

Also check if your EmailHelper is doing any DML operations. If it updates a field on the opportunity (like ‘Last Alert Sent’), that triggers the same trigger again. Move any DML to a separate transaction using @future or Queueable to break the recursion chain.

We debugged similar issues by enabling debug logs with FINEST level on the Apex Code category. You’ll see multiple TRIGGER_BEGIN entries for the same records. Count them to confirm recursion depth. Usually it’s 2-3 cycles before hitting governor limits or your logic accidentally stops it.