To prevent duplicate permit-expiration emails in NestJS, coordinate scheduled work across application replicas, give each intended notice a stable business-event key, and persist deduplication state long enough to cover retries. For reliable recovery when a database change and notification intent must stay in sync, use a transactional outbox. These measures prevent repeated application work; they do not guarantee that an external email provider delivers a message exactly once.
Why NestJS can send the same scheduled email more than once
NestJS runs scheduled jobs in every process of an application. If a deployment has multiple replicas, each replica can run the same permit-expiration task and attempt to send the same notice. The NestJS Task Scheduling documentation states: “The scheduler runs every job in every process of your application.”
A second source of duplicates is repeated work: a task may overlap with its next scheduled run, a producer may retry queueing a notice, or a worker may retry after a failure. Preventing one replica from starting a task does not address all these cases. The design needs both coordination and an identity for the business notification.
Use a stable key for each intended notice
Define what counts as the same email before choosing a lock or queue setting. A practical key can combine the permit ID, notification type, and expiration period or permit version. That identifies the underlying notice even if two cron ticks discover it, or a process retries after a crash.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →#1 Best Overall
A cron tick timestamp is usually a poor deduplication key: each run has a different timestamp even when it discovers the same permit and same expiration event. Keep the business-event key stable across retries and replicas. The exact fields depend on whether the application intends one reminder per permit, one per expiration date, or a new notice after a permit’s expiration details change.
Coordinate scheduled work across replicas
Use a shared distributed lock for one-run-per-tick work
If only one instance should perform a scheduled scan or dispatch per tick, have all replicas contend for a lock in shared infrastructure, such as a shared Redis service. Use an explicit, stable lock key; deriving identity from a class or method name can change behavior if code is renamed during deployment.
Prefer renewable leases with fencing tokens where supported. A simple Redis key with a time-to-live has a failure mode: the lock holder can pause longer than the TTL, allowing another process to acquire the key while the first process is still running. Lease renewal, lock-loss handling, and fencing help prevent a stale holder from continuing as if it still owns the work. NestJS documents these distributed-lock considerations in its queues guidance.
Prevent overlap separately
If a task can run longer than its schedule interval, prevent a process from starting a second copy while its earlier run is still active. A local overlap control handles that per-process condition only; it does not elect one winner among several replicas. Use it alongside, not instead of, a shared lock when both problems apply. See the NestJS scheduling documentation for scheduler behavior and controls.
Rank #3
Deduplicate queued work and retain state for the needed period
For asynchronous processing with BullMQ, assign a deterministic custom job ID or use a deduplication ID that matches the notification’s intended lifetime. If a job with that identity is already present, BullMQ does not add it again. However, if completed or failed jobs are removed, that identity may become available again and a later producer attempt can add another job.
Choose the deduplication mechanism based on how long duplicates must be suppressed. If the guarantee needs to outlast queue retention, store a durable claimed or sent record and enforce a unique business key in the database. Queue-level deduplication is useful for repeated producers, but it is not a permanent record unless the relevant state is retained. Consult the BullMQ job ID documentation and BullMQ deduplication documentation for their behavior.
Rank #4
Use an outbox when the permit update and email intent must commit together
If changing a permit and deciding to send its expiration email are part of the same operation, write an outbox row in the same database transaction as the permit change. A separate publisher reads pending outbox rows, queues or sends the corresponding notification, and records progress. If the application crashes after the transaction, the pending intent remains available for publication.
Calling Queue.add() does not automatically make that queue operation part of the application’s database transaction: NestJS notes that the ordinary call uses BullMQ’s own pool in autocommit mode. The outbox closes the database-to-queue gap, but publication is still a later step and needs retries, deduplication, and monitoring of its own. See the NestJS queues documentation.
Do these 3 things before closing this tab:
1Scan for outdated or missing drivers - takes under a minute2Repair Windows errors before they cause bigger problems3Fix the driver behind crashes, sound loss and screen glitchesBest Value
Handle email-provider outcomes as potentially ambiguous
A timeout does not tell the application whether the provider accepted the message. Record attempts and final state, and reconcile uncertain outcomes rather than assuming that a retry is safe. Use a provider’s idempotency feature only if its current contract explicitly supports the operation being performed. A lock, database uniqueness constraint, outbox, or deduplicated queue job cannot by itself establish exactly-once delivery to a recipient.
Choose controls for the failure you need to prevent
| Approach | Useful when | Key limit |
|---|---|---|
| Shared distributed lock | A scheduled scan or dispatch should run on one replica per tick. | Coordinates execution, not provider delivery. Use stable lock identity, lease renewal, and lock-loss handling. |
| BullMQ job ID or deduplication ID | Repeated producers may enqueue the same notice and Redis-backed asynchronous processing fits the system. | Duplicate detection depends on the job or deduplication state being retained; removal can permit a later duplicate. |
| Transactional outbox | The permit update and intent to notify must not diverge during a crash. | Publication is a separate step; downstream retries still need idempotency and monitoring. |
| Local overlap prevention | A long-running task must not overlap with another run in the same process. | Does not coordinate multiple replicas. |
Compare the options by whether they coordinate multiple instances, recover after crashes, retain deduplication state for the required lifetime, make notification intent atomic with permit updates, and expose enough state for operations.
Monitor duplicate prevention and missed runs
A scheduled task can stop running without throwing an error, so watch both failed executions and missing expected runs. Log the permit or event key with the schedule time, lock or deduplication result, provider response, and persisted notification state. This makes it possible to distinguish a suppressed duplicate from a task that never ran or a send whose outcome remains uncertain.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

