This article is currently available in English only. Translation coming soon.
You launch an Odoo mass mailing to 10,000 recipients. After 30 minutes, the campaign shows "in progress". After an hour, the worker dies with:
ERROR: Worker (12345) exceeded limit_time_real (3600), terminating
WARNING: mass.mailing batch incomplete: sent 4327, remaining 5673
Some recipients got the email, some did not, and re-sending risks duplicates. This is a classic batch-timeout problem in Odoo 17.0/18.0/19.0 — fixable with worker tuning, batch sizing, and queue strategy.
Quick Fix
Raise the worker's wallclock limit and lower the batch size:
# odoo.conf
limit_time_real_cron = 14400 # 4 hours wallclock max for cron
limit_time_real = 600 # 10 minutes for HTTP requests
mail_mass_mailing_batch_size = 200 # smaller batches resume faster
Restart Odoo, then resume the campaign:
mailing = self.env['mailing.mailing'].browse(<id>)
mailing.action_send_mail() # resumes from where it left off
For ongoing or chronic timeout issues, install OCA's queue_job and route mass mailing through it.
Why This Happens
Odoo mass mailing flow:
- Campaign creates
mailing.tracerows for each recipient. - A cron picks up traces in batches and sends them.
- Each send is one SMTP round-trip plus DB writes.
The five common timeout causes:
- Worker time limit too low. Odoo's
limit_time_real_crondefaults to 1 hour. A 10K-recipient send at 0.5 sec each = 83 minutes — does not fit. - SMTP rate limit. Gmail throttles to 100/minute, SES to ~200/sec — Odoo waits between sends, the wait pushes total time over the limit.
- DB contention. Heavy writes to
mail.mailandmailing.traceplus other concurrent activity slow each send. - Memory growth during send. As the cron processes more emails, ORM cache grows, RAM bloats, eventually the worker is killed.
- DKIM signing or external image embedding adds latency per send. Cumulative latency exhausts the time budget.
Step-by-Step Diagnosis
1. Check campaign status.
SELECT m.id, m.name, m.state, m.sent, m.total
FROM mailing_mailing m
WHERE m.id = <campaign_id>;
sent < total with state='sending' for hours = stuck batch.
2. Check mailing.trace for incomplete sends.
SELECT trace_status, count(*) FROM mailing_trace
WHERE mass_mailing_id = <id>
GROUP BY trace_status;
outgoing rows are unsent. error rows failed for a reason — read failure_reason.
3. Check worker logs around the timeout.
grep "limit_time_real\|exceeded\|terminated" /var/log/odoo/odoo.log | tail
OOM-killed workers mean memory growth, not just timeout. Address with batch tuning.
4. Measure single-send latency. Send to 1 test recipient, time the worker:
import time
start = time.time()
mailing._send([test_recipient_id])
print(f"Per-recipient: {time.time() - start:.2f} sec")
If single sends are slow (over 0.5 sec), reduce send latency before increasing volume.
5. Check provider rate limits. Look at SES SendStatistics or Gmail's API quota. Throttled sends queue, dramatically increasing total time.
Permanent Fix
Tune worker time limits for mass mail:
# odoo.conf
limit_time_real_cron = 14400 # 4 hours per cron
limit_memory_soft = 2147483648 # 2 GB before recycling
limit_memory_hard = 3221225472 # 3 GB before kill
max_cron_threads = 2
For very large campaigns, consider running a dedicated cron-only Odoo instance with its own tuning, separate from web workers.
Reduce batch size and add intra-batch commits:
# In a custom override of mailing.mailing
@api.model
def _process_mass_mailing_queue(self):
BATCH_SIZE = 100
pending = self.env['mailing.trace'].search([
('trace_status', '=', 'outgoing')
], limit=BATCH_SIZE)
while pending:
for trace in pending:
try:
trace._send()
except Exception as e:
trace.write({
'trace_status': 'error',
'failure_reason': str(e),
})
self.env.cr.commit() # release locks, persist progress
self.env.invalidate_all() # cap memory growth
pending = self.env['mailing.trace'].search([
('trace_status', '=', 'outgoing')
], limit=BATCH_SIZE)
Smaller batches mean smaller transactions, faster recovery from failure, and more frequent garbage collection.
Use queue_job for proper scaling:
from odoo.addons.queue_job.job import job
class MailingMailing(models.Model):
_inherit = 'mailing.mailing'
@job(default_channel='root.mail')
def _send_one(self, trace_id):
self.env['mailing.trace'].browse(trace_id)._send()
def action_send_mail_async(self):
for trace in self.mailing_trace_ids.filtered(lambda t: t.trace_status == 'outgoing'):
self.with_delay()._send_one(trace.id)
queue_job processes mails outside the request/cron cycle. Scales horizontally — add queue workers, throughput rises linearly.
Throttle to provider limits:
import time
class MailingMailing(models.Model):
_inherit = 'mailing.mailing'
SEND_RATE_PER_SEC = 50 # SES safe default
def _process_mass_mailing_queue(self):
last_send = time.time()
for trace in self.mailing_trace_ids.filtered(lambda t: t.trace_status == 'outgoing'):
elapsed = time.time() - last_send
wait = max(0, (1.0 / self.SEND_RATE_PER_SEC) - elapsed)
if wait:
time.sleep(wait)
trace._send()
last_send = time.time()
Self-throttled sending stays under provider limits. Pair with provider-specific knowledge — SES is 14/sec on cold accounts, 200/sec when warmed.
Resume on partial failure. Mass mailing should be idempotent: if a worker dies mid-batch, the next cron run should pick up from where it left off. Odoo's default behaviour does this via mailing.trace.trace_status='outgoing' filter — verify your custom code preserves this.
How to Prevent It
- queue_job for mass mailing. Single biggest reliability win for high-volume sending. Decouples send work from cron lifecycle.
- Test at scale in staging. Send a 5K test campaign in staging before launching 50K in production. Catches batch-size and rate-limit issues before they affect customers.
- Provider rate awareness. Know your provider's limit. SES, Gmail, Mailgun all publish rate caps. Throttle below the cap with margin.
- Monitor
mailing.tracequeue depth. Alert when outgoing count exceeds threshold or grows monotonically. Catches stalls before customers complain. - Smaller batches by default. 100 to 500 per batch is the sweet spot for most campaigns. Larger batches risk timeout; smaller batches risk overhead.
- Use a dedicated sending domain. Improves deliverability (warm-up isolated from transactional traffic) and lets you set per-domain rate limits at the provider.
Related Errors
- Mail stuck in outgoing queue — adjacent send-failure issue.
- Failed email no error log — silent-failure variant.
- Cron job stuck running — same family for any long-running job.
- Memory leak in cron worker — what mass-mailing crons can become.
Frequently Asked Questions
How fast can Odoo realistically send mass mail?
Single-server, single-cron-thread, with SES and a warmed sending domain: 10K mails in about 8 minutes (around 20/sec including DB and DKIM overhead). With queue_job and 4 workers, around 75/sec sustained. Beyond that requires dedicated infrastructure or a transactional service like SendGrid running alongside Odoo.
Should I use Odoo's built-in mass mailing or an external service?
For under 50K/month: Odoo's built-in is fine. Above that, integrate a transactional service like SendGrid or Mailgun via API. The OCA mass_mailing_partner and similar modules provide hooks. Hybrid is common: transactional via SES, marketing via dedicated provider.
Can I pause and resume a mass mailing?
Yes. Pause: change state from sending to draft. Resume: re-run action_send_mail(). The pending traces in outgoing state pick up where they left off. Avoid editing the campaign content between pauses — partial recipients see different versions.
How do I avoid sending duplicates after a crash?
Idempotent design. Each recipient gets one mailing.trace row. The send loop only processes traces with state='outgoing'. Successful sends update to state='sent'. A crash leaves rows in outgoing; resumption picks them up. As long as your custom send code respects this state machine, duplicates are impossible.
Need help with a tricky Odoo error? ECOSIRE's Odoo experts have shipped 215+ modules — get expert help.
تحریر
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Odoo ERP کے ساتھ اپنے کاروبار کو تبدیل کریں
آپ کے کاموں کو ہموار کرنے کے لیے ماہر Odoo کا نفاذ، حسب ضرورت، اور معاونت۔
متعلقہ مضامین
How to Add a Custom Button to an Odoo Form View (2026)
Add custom action buttons to Odoo 19 form views: Python action method, view inheritance, conditional visibility, confirmation dialogs. Production-tested.
How to Add a Custom Field in Odoo Without Studio (2026)
Add custom fields via custom module in Odoo 19: model inheritance, view extension, computed fields, store/non-store decisions. Code-first, version-controlled.
How to Add a Custom Report in Odoo Using External Layout
Build a branded PDF report in Odoo 19 using web.external_layout: QWeb template, paperformat, action binding. With print logo + footer overrides.