N8n on becoming slower over time (40k+ executions/month)

Describe the problem/error/question

We’re running a self-hosted n8n instance on Railway and have been experiencing increasing performance issues as our usage has grown.
We currently process more than 40,000 workflow executions per month.
The main issue is that workflows that should normally complete in around 1 second are now frequently taking 3–4 seconds to finish, even though the workflow logic itself hasn’t changed.
We’re also seeing execution queues forming randomly. This shouldn’t happen based on our current configuration, since we haven’t intentionally configured any concurrency limits.
More recently we’ve also started seeing this error:
This execution failed to be processed too many times and will no longer retry. To allow this execution to complete, please break down your workflow or scale up your workers or adjust your worker settings.

We honestly don’t know what else to try. The Railway metrics for our workers, primary instance, and PostgreSQL all look healthy, with no obvious resource bottlenecks. Has anyone experienced something similar or have any ideas on what we should investigate next?

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: 2.28.4
  • Database: PostgreSQL
  • n8n EXECUTIONS_PROCESS setting (default: own, main):
  • Running n8n via (Docker, npm, n8n cloud, desktop app): railway
  • Operating system: windows 11

Hi @jptg

Based on the symptoms and the error message you’re seeing, your n8n instance is likely suffering from Process Overhead and Database Bloat​, which are common as self-hosted instances scale toward 40k+ executions per month.

The error “This execution failed to be processed too many times” typically occurs when an execution is picked up by a worker, but the worker fails to “check-in” or finish before the lock expires. The system assumes the worker crashed and retries the job until it hits the limit.

You mentioned the EXECUTIONS_PROCESS setting. If you are using the default own mode, n8n spawns a brand new Node.js process for every single execution​.

  • The Problem: This adds significant overhead (CPU and RAM) and creates a 1–3 second “cold start” delay for every workflow. As your usage grows, this puts immense pressure on the OS scheduler and memory.
  • The Fix: Change your environment variable to: EXECUTIONS_PROCESS=main

If you are running n8n in Queue Mode (using separate workers), you are likely hitting a lock timeout. Even if a workflow takes only 4 seconds, database contention or network jitter on Railway can cause the “heartbeat” to fail.

  • The Fix: Increase the lock duration to give workers more breathing room. Add this environment variable: QUEUE_WORKER_LOCK_DURATION=120000 (This increases the lock from 60s to 120s).

Railway metrics show CPU/RAM, but they don’t show PostgreSQL table bloat​. With 40,000+ executions/month, your execution_entity table can grow massive, slowing down the very queries n8n uses to manage the queue.

  • The Investigation: Check if you have execution pruning enabled. If the database is bloated, even “healthy” CPU metrics won’t save you from slow I/O.
  • The Fix: Ensure these variables are set to keep your database lean:
    • EXECUTIONS_DATA_PRUNE=true
    • EXECUTIONS_DATA_MAX_AGE=168 (Prunes data older than 7 days; adjust as needed).
    • EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000 (Caps the total records).

Since you haven’t set concurrency limits, a sudden burst of webhooks can trigger dozens of simultaneous processes, causing the “random queues” and performance dips as the system thrashes.

  • The Fix: Set a global ceiling to protect your instance: N8N_CONCURRENCY_PRODUCTION_LIMIT=10 (Start with 10 and increase if your Railway resources allow).

Hi, thanks for the detailed reply!

We’re already running in Queue Mode, and we’ve now applied almost all of your suggestions.

  • We already had Queue Mode configured.
  • We’ve enabled execution pruning (EXECUTIONS_DATA_PRUNE, EXECUTIONS_DATA_MAX_AGE, etc.).
  • We’ve also increased the worker lock duration.

The only suggestion we didn’t apply was EXECUTIONS_PROCESS=main, because from what we understand this setting is deprecated in recent n8n versions and isn’t applicable when using Queue Mode.

At the moment we haven’t had a chance to validate whether these changes improved the situation, since the performance degradation happens intermittently. We’re waiting for the next incident to see if the issue reappears.

For me I will run multiple instances of n8n to avoid this all together.

My last position I had an Instance for each department of the company

Service
Operations
Marketing
HR
Development (my workflow graveyard)

which also drove me to create a management platform for them lol

You’ve actually done the right things — queue mode, pruning, the lock bump — and you’re right that EXECUTIONS_PROCESS is deprecated (own-process mode was removed; on modern n8n it’s all main or queue/worker, so that one was a no-op for you). The catch is you changed four variables at once with no baseline, so even if it improved you can’t tell which knob did it. Before turning more, measure — here’s how to tell which of the three usual bottlenecks you actually have: DB I/O, worker contention, or a single leaking workflow.

Enabling pruning doesn’t reclaim what’s already there. EXECUTIONS_DATA_PRUNE only stops new rows piling past your threshold going forward — it doesn’t shrink a table that’s already bloated. In Postgres those deleted rows become dead tuples, and the on-disk size of execution_data (the payload table — the big one, not execution_entity) plus its indexes stays large until it’s vacuumed, and autovacuum often can’t catch up on a table that already got huge. So “we enabled pruning and nothing changed” is exactly what you’d expect if your slowness is DB I/O. Check it directly:

  • `SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;— ifexecution_datahas millions of dead tuples andlast_autovacuum` is old or null, that’s your answer.
    • SELECT pg_size_pretty(pg_total_relation_size('execution_data')); — if the physical size is huge, pruning going forward won’t help; you need a pg_repack (runs online, no long lock) to actually reclaim it. VACUUM FULL works too but locks the table.
  • Your error message is a specific signal, not general slowness. “This execution failed to be processed too many times” is the stalled-job path: a worker leased the execution, didn’t finish or heartbeat before the lock expired, it got re-queued, and after N attempts it’s marked failed. Your lock bump only helps if the cause is genuinely long executions. The one that bites people on Railway and hides behind “healthy CPU” is a worker hitting its memory limit — Railway silently restarts a container that exceeds its RAM ceiling, and every in-flight execution on that worker throws exactly this error. CPU% looks fine because the kill is memory, not CPU. Look at the worker service’s restart count and memory graph (not CPU) and check whether restarts line up with the failures.

One more, if any workflow moves files. At 40k/month, if you handle PDFs/images and binary data mode is still default, that’s a memory + DB bloat source, and it’s also unreliable across multiple workers (the file lands on one worker’s local disk). N8N_DEFAULT_BINARY_DATA_MODE=s3 if so — ignore if you’re all-JSON.

Order I’d go: the two Postgres queries first (rules DB in or out in about a minute), then the worker restart/memory graph (rules out OOM), then change one thing and watch one number so the next round is actually measurable.

If it’s useful: pinning down which of these is actually your bottleneck from your real pg_stat output and worker metrics is the kind of teardown I do as a fixed $49 written diagnosis — you send the sanitized query results + the worker memory graph, I send back a root-cause and a prioritized fix list, async, no call. If you then want it watched continuously — alerting on worker OOM-restarts and queue-wait before they turn into failed executions — that’s a $149/mo monitoring setup. But run those two Postgres queries first; if it’s just accumulated bloat, a pg_repack fixes it and you won’t need me.

If he wanted AI replies he would have used claude, chagpt etc.

One follow up with a point I should have included the first time, because it is the thing that makes the pruning advice look like it did not work.

If you did run the two pg_stat queries and execution_data is still huge, check whether the rows are actually gone or only marked deleted before you conclude pruning is broken. n8n’s pruning does the delete in stages, so there is a window where executions are flagged for removal but the payload rows are still physically present, and on a busy instance that has been restarting the flagged backlog can sit there indefinitely. Comparing the row count in execution_entity against what the UI shows you as existing executions is usually enough to tell which situation you are in, and it changes the fix entirely: if the rows are already gone, you have dead tuple bloat and need a repack, and if they are still there, no amount of vacuuming will help until they are actually removed.

The second half matters specifically on Railway. pg_repack rebuilds the table alongside the original before swapping, so it needs free disk roughly equal to the size of the table and its indexes. If execution_data is most of the volume, the repack will run for a while and then fail on disk, and you will be in a worse position than when you started. Check free space against pg_total_relation_size first. If the headroom is not there, the cheaper route is to bring the row count down hard first, in batches so you are not holding one enormous transaction, and only then reclaim the space.

Worth saying plainly that if the two queries pointed at the worker rather than the database, none of the above is your problem and I would ignore it.