Solving Concurrency & Execution Isolation in a Multi-Tenant n8n Architecture

I’m facing a major scalability issue in my multi-tenant n8n WhatsApp automation system.
Multiple webhook events trigger workflows simultaneously, causing:
• Race conditions
• Duplicate executions
• Queue congestion
• Database collisions
• Credential isolation risks
The challenge is maintaining execution isolation and preventing duplicate message processing while scaling workers horizontally in queue mode.
const lockKey = msg:${messageId};
const lock = await redis.set(
lockKey,
“processing”,
“NX”,
“EX”,
60
);
if (!lock) {
return “Duplicate execution blocked”;
}

Describe the problem/error/question

What is the error message (if any)?

Please share your workflow

(Select the nodes on your canvas and use the keyboard shortcuts CMD+C/CTRL+C and CMD+V/CTRL+V to copy and paste the workflow.)

Share the output returned by the last node

Information on your n8n setup

  • n8n version:
  • Database (default: SQLite):
  • n8n EXECUTIONS_PROCESS setting (default: own, main):
  • Running n8n via (Docker, npm, n8n cloud, desktop app):
  • Operating system:

Hi @Keira_Becky To solve this issue properly, I would use: Redis distributed locking to prevent duplicate executions
Idempotency keys for webhook validation
Queue mode with multiple workers for horizontal scaling
Tenant-based execution context isolation to prevent credential crossover
Retry-safe workflow design with dead-letter handling
PostgreSQL transaction control for safe database operations

The main goal is ensuring that each webhook event is processed exactly once, even under high concurrent traffic.

good morning @Keira_Becky
I would remove the sorting from within the workflow and put it in an external queue partitioned by tenantId + conversationId. This way, messages from the same conversation are processed in order, while different tenants/conversations keep running in parallel. I would still maintain a unique constraint in the database with tenantId + messageId as a final layer of protection against duplicates.

The Redis distributed lock approach you’re already using is the right instinct. For the credential isolation piece specifically - use a single “router” workflow that reads tenant config and passes credentials as parameters to sub-workflows via the Execute Workflow node. Each sub-workflow doesn’t store credentials in its own nodes; it receives the credential name or ID dynamically and uses the Credential node’s expression-based selection. This keeps the shared workflow code in one place while tenant state stays in your DB. For the concurrency cap per tenant, set QUEUE_WORKER_CONCURRENCY at the worker level and route high-volume tenants to dedicated worker instances using queue name labels if you need true isolation at scale.

two bugs in the redis lock. EX 60 is too short — if processing takes longer (Gemini hangs, openai slow, whatever) the lock expires and Meta’s next duplicate webhook acquires it and runs parallel. bump ttl to ~900s and release explicitly on success.

bigger issue is the key. msg:{messageId} dedupes Meta’s retries fine, but doesnt serialize per-conversation — two messages from the same user 200ms apart still process in parallel and reply ordering breaks. u need both: idempotency_key msg:{tenantId}:{messageId} for retries + serialization_key conv:{tenantId}:{conversationId} for FIFO.

real “exactly once” comes from DB-side anyway — INSERT … ON CONFLICT (tenant_id, message_id) DO NOTHING on ur outbound record. lock is for performance, DB constraint is for correctness.

The DB-based credential pattern is the stronger approach - storing encrypted_credentials in a separate table means the workflow code has zero hardcoded tenant context, and a compromised workflow export can’t leak credentials. The INSERT … ON CONFLICT DO NOTHING point is also something I didn’t cover - that’s the right way to enforce exactly-once at the data layer rather than relying solely on the lock. Good additions.

Hi @Keira_Becky Your approach is already correct Redis locking is the right direction.

What you’re solving:Prevent duplicate webhook runs
Avoid race conditions in multi-worker setup

Do this to make production safe

  1. Add tenant isolation to the lock:const lockKey = tenant:${tenantId}:msg:${messageId};
  2. Make sure to protect the database:ON CONFLICT (message_id) DO NOTHING

Webhook → Redis Lock → Queue → Worker → DB

Redis lock stops duplicates early, but DB idempotency is your final safety net.

Thanks @Emmas @achamm @syed_noor @Niffzy @tamy.santos @nguyenthieutoan

For the reply I really appreciate and learn a lot of approach from the replies

Your welcome! Happy we were all able to help you out!

You’re welcome @Keira_Becky

القيد الفريد على (tenant_id, message_id) هو ما يجعل هذا آمنًا فعلاً، وجدير بفصله عن القفل، لأن الاثنين يقومان بوظائف مختلفة. INSERT … ON CONFLICT DO NOTHING هو حد صحتك. قفل SET NX EX هو تحسين أفضل جهد حتى لا ينفق العامل وقتًا على رسالة التقطها عامل آخر بالفعل. إذا اضطررت يومًا ما للاختيار بين أيهما تثق، فثق بالقيد.

هذا الفصل مهم بسبب الحالة التي أشارت إليها achamm: استدعاء Gemini أو OpenAI بطيء يتجاوز TTL لمدة 60 ثانية. نقل TTL إلى 900 ثانية يجعل ذلك أقل تكرارًا، لكنه لا يجعله آمنًا. عقد SET NX EX يتوقف عن كونه استبعادًا متبادلاً في اللحظة التي يمكن للقسم الحرج فيها أن يتجاوز العقد، وليس لدى العامل طريقة للتمييز بين “أنا لا أزال أمسك القفل” و “انتهت صلاحية عقدي قبل 200 ميلي ثانية وشخص آخر يمسكه الآن.” توقف GC، استدعاء HTTP متوقف، أو تقسيم Redis قصير كلها تنتهي في نفس المكان. الحل ليس عقدًا أطول، بل رمز الحماية: زيادة عداد لكل محادثة عند الاستحواذ، حمله عبر التشغيل، واجعل جانب الكتابة يرفض أي شيء يصل برمز قديم. إذا كان التأثير بالفعل خاليًا من التأثيرات على قاعدة البيانات، وهو ما يعطيك ON CONFLICT لكل رسالة، فيمكن للقفل أن يبقى أفضل جهد وتتوقف عن الاعتماد على توقيته لتحقيق الصحة.

لغم محدد لـ n8n جدير بالإشارة إليه: لا تحافظ على أي حالة إزالة تكرار في $getWorkflowStaticData(). في وضع قائمة الانتظار، فهي ليست آمنة لحالة التنفيذ المشترك. يقرأ المحرك كل بلوب staticData في الذاكرة عند بدء التنفيذ، ويغير نسخة، ويكتب العمود كاملاً مرة أخرى في النهاية بـ UPDATE عادي على صف سير العمل (saveStaticDataById في workflow-static-data.service.ts على 2.x). لا توجد تسلسل كل سير عمل. خدمة التحكم في التزامن مفتاحها عام على الإنتاج/التقييم وتُطفأ تماماً في وضع قائمة الانتظار، لذلك يقرأ تشغيلان من نفس سير العمل نفس الخط الأساسي والكتابة اللاحقة تستبدل بصمت الكتابة السابقة. إنها نفس آخر كتابة تفوز التي اعترفت بها الفريق للحفظ المتزامن لسير العمل في #11638. احتفظ بمؤشرات وعدادات لكل مستأجر في Redis أو Postgres حيث تملك الذرية.

نقطة عزل بيانات الاعتماد هي محور منفصل عن محور التزامن ويستحق احتمالاً موضوعه الخاص، لأن نمط بيانات الاعتماد الديناميكية عبر Execute Workflow لها أوضاع فشل لا علاقة لها بإزالة التكرار.

أنا سعيد بمشاركة شكل مسار قراءة وكتابة staticData في مصدر 2.x إذا ساعدك على استبعاده أو إدراجه في إعدادك.