Two silent failures in a weekly report
The weekly SEO report email stopped for three days without a single error. The cause was a lock that was never released, and behind it, a queue that starved itself.
- Status
- Fixed and running daily
- When
- Aug 2026
- Role
- Built the report, then found and fixed both failures
- Team
- Me; the stale-aware lock helper was a teammate's
- Stack
- TypeScript, Node.js, node-cron, Prisma, Resend
- Links
- babylovegrowth.ai
- 3 days1
- without a single report email, and without a single error
- 2 min1
- cap on each site's Search Console and email work after the fix
- 21
- separate silent failures, found and fixed two days apart
Summary
In August 2026 I shipped a weekly SEO report email at BabyLoveGrowth.ai: organic clicks and impressions, keywords that moved, a ten-week click chart, new articles, backlinks and AI visibility, one email per site. Four days later I found that nothing had gone out for three days. No job had failed and nothing had been logged as an error.
There were two bugs, one behind the other. A run that hung on one site never released its lock, so every later run skipped itself. Once that was fixed, a second one surfaced: sites whose emails failed were never marked as attempted, and because the queue serves the sites sent to least often, the same failing sites took every slot.
Failure one: a lock that never released
The job guarded itself with a module-level isRunning flag, set at the start of a run and cleared in a finally block. On 17 August one site's network call never returned. The promise never settled, so the finally never ran, and from then on every 08:00 tick saw the flag, logged that a run was already in progress and returned. From the outside, a stuck job and an idle one looked identical1.
The fix had two parts. I moved the job onto the team's stale-aware run lock, written by a teammate, which lets a new run take over once a holder is older than two ticks and reports the takeover to Sentry. Then I put a two-minute timeout around each site's Search Console queries and email send, so one slow site can only cost two minutes instead of the whole run.
Before
- 17 AugHung
- 18 AugSkipped
- 19 AugSkipped
- 20 AugSkipped
- 21 AugFix shipped
After
- Day 1Hung
- Day 2Skipped
- Day 3Took over
- Day 4Ran
- Day 5Ran
Failure two: a queue that starved itself
With runs completing again, sends still dwindled day by day. The report picks the sites it has emailed least often first, but a site was only marked as sent when its email succeeded. Sites that failed every time kept the lowest count, sorted to the front every morning and filled the run before any healthy site got a turn.
The fix was one line in spirit: record the attempt in finally, whether the email went out or not, so a failing site moves to the back of the queue like everyone else1.
What both bugs had in common
Neither failure threw. One was an await that never settled, the other was a sort order working exactly as written. Error tracking cannot see either, because nothing errored. What would have caught both is a check on the outcome: alert when a job that should send emails sends none, or when the same sites are attempted day after day.
The rules I took from it: a module-level running flag is a bug on sight, every external call inside a cron job gets a timeout, and a queue that orders by history records attempts, not only successes.
What I would change
The report shipped without a signal for “zero emails today”, and that is the one alert that would have turned three silent days into one. I would add that expectation to the job before the first run, not after the first outage.
Sources
- 1.BabyLoveGrowth backend pull requests for the weekly report, its schedule and both fixes, 17 to 23 Aug 2026. Private repository. The two-minute constant is SITE_REPORT_TIMEOUT_MS = 2 * 60 * 1000.