GDPR Article 7(3) and CAN-SPAM §7704(a)(4) impose time-bounded opt-out obligations with no exception for queue backlog. When a large campaign send floods the job queue at the same time as incoming opt-out requests, a FIFO queue without priority differentiation can delay opt-out processing by hours — pushing real contacts into violation territory. Separately, if the campaign recipient list is built at scheduling time (hours before send), contacts who opted out after list construction will be in the send batch regardless of the queue's behavior. Both failure modes cause sends to contacts who have legally withdrawn consent.
Medium because queue backlog delays opt-out processing into regulatory violation territory under high send volumes, and stale recipient lists create a deterministic window where opted-out contacts are unreachable by the suppression system.
Set explicit job priority in BullMQ (lower number = higher priority) and rebuild the recipient list immediately before send, not at schedule time:
// src/workers/queue.ts — opt-out jobs at priority 1, campaign sends at priority 10
await queue.add('process-optout', data, { priority: 1 })
await queue.add('send-campaign-email', data, { priority: 10 })
// src/lib/campaigns/recipients.ts — called at send time, not schedule time
async function getCampaignRecipients(campaignId: string): Promise<string[]> {
const baseList = await db.campaignAudience.findMany({ where: { campaignId } })
const ids = baseList.map(r => r.contactId)
const suppressed = await db.contact.findMany({
where: { id: { in: ids }, optOutStatus: { not: null } }
})
const suppressedSet = new Set(suppressed.map(c => c.id))
return ids.filter(id => !suppressedSet.has(id))
}
Never cache the recipient list for more than 5 minutes before the actual send batch starts.
ID: compliance-consent-engine.opt-out-processing.backlog-respect
Severity: medium
What to look for: When the job queue is backed up (e.g., during a large campaign send that also triggered many bounces and opt-outs), check whether opt-out jobs take priority over other jobs. Also verify that the pre-send recipient list is constructed no earlier than N minutes before send — a recipient list built 3 hours before the campaign starts will include contacts who opted out after the list was built. Look for recipient list build timing and queue priority configuration.
Pass criteria: Opt-out jobs have a higher priority than campaign send jobs in the queue configuration. OR the recipient list is built immediately before send (within no more than 5 minutes), ensuring freshly opted-out contacts are excluded. A pre-send suppression check runs against the live database, not a cached list. Count all queue job types and enumerate their priorities — opt-out must have the highest priority.
Fail criteria: Opt-out jobs sit behind campaign processing jobs with no priority differentiation. Recipient lists are built hours before send and used without a pre-send suppression refresh.
Skip (N/A) when: No queue system in use, or all opt-out processing is synchronous.
Detail on fail: "BullMQ queue has no job priority — opt-out jobs processed FIFO behind 50k campaign send events" or "Campaign recipient list is computed 4 hours before send at scheduling time — no pre-send suppression refresh"
Remediation: Set queue priority and use fresh recipient lists:
// BullMQ: opt-out jobs at higher priority (lower number = higher priority)
await queue.add('process-optout', data, { priority: 1 })
await queue.add('send-campaign-email', data, { priority: 10 })
// Build recipient list at send time, not schedule time
async function getCampaignRecipients(campaignId: string): Promise<string[]> {
// Called immediately before send batch starts
const baseList = await db.campaignAudience.findMany({ where: { campaignId } })
const suppressed = await db.contact.findMany({
where: { id: { in: baseList.map(r => r.contactId) }, optOutStatus: { not: null } }
})
const suppressedIds = new Set(suppressed.map(c => c.id))
return baseList.filter(r => !suppressedIds.has(r.contactId)).map(r => r.contactId)
}