In March the Postgres database behind our WHMCS automations was 4.2 GB, the executions page took eleven seconds to open and the main process sat at 1.4 GB resident with nothing running. Three environment variables and one prune later the database was 310 MB and the page opened in under a second. That's the order this guide follows: the changes with the biggest effect on a VPS come first and cost nothing, the workflow redesign comes last because it costs your time. Everything applies to n8n 2.x in regular mode on one server; queue mode has its own knobs and they're in the guide on scaling n8n workers.
Execution data settings
Every execution writes its full input and output for every node into the execution_data table by default, successful or not. On a workflow that runs every minute that is 1,440 rows a day of JSON you'll never look at, and the executions page has to count and sort across all of it.
Stop saving successful runs
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_ON_PROGRESS=false
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=false
Those four are the ones that did most of the 4.2 GB. Successful runs still show in the list with their status and timing; what's gone is the per-node payload. Errors keep everything so you can debug them. SAVE_ON_PROGRESS writes after each node, not once at the end, which is only useful if you expect crashes mid-run and costs a database write per node. Manual runs from the editor are dropped because they're the biggest payloads of all: n8n copies the data for the frontend, and a test run of a 10,000-row import stored twice is how the table grows fastest. Any individual workflow can override these in its settings panel, so keep success data on the two or three workflows where you need the audit trail and switch it off for the rest.
Pruning limits
Pruning is on by default in 2.x with EXECUTIONS_DATA_MAX_AGE=336 hours and EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000. I run 168 and 50000, which is the example the docs give. The soft delete, hard delete and Postgres vacuum behaviour that follows a prune is its own topic and it's covered in the guide to pruning n8n executions, including why the space doesn't come back on SQLite until you vacuum.
N8N_EXECUTION_DATA_STORAGE_MODE
Newer 2.x releases can keep execution data out of the database entirely. N8N_EXECUTION_DATA_STORAGE_MODE accepts database (the default), filesystem, s3 and azure, with the last two on Business and Enterprise plans; in filesystem mode the payloads go under N8N_STORAGE_PATH, which defaults to storage inside the user folder. For a single regular-mode instance on a VPS that means Postgres holds the metadata and the disk holds the blobs. I have not switched a production instance to it, so I can't say how it affects the executions page or what a prune does to the files; it's on my list to test on staging and I'd treat it the same way until you've seen it work.
Postgres over SQLite and DB_POSTGRESDB_POOL_SIZE
If the instance is still on SQLite and the file is past a few hundred megabytes, the database is the bottleneck and nothing else on this page fixes that; the move is documented in the PostgreSQL vs SQLite for n8n comparison. On Postgres, DB_POSTGRESDB_POOL_SIZE defaults to 2 connections, which sounds low and is fine for most instances because n8n's queries are short. Raise it to 4 or 6 if the log shows connection acquisition timeouts under load (DB_CONNECTION_ACQUISITION_TIMEOUT_MS, 30000 by default) or if the instance runs many concurrent executions with database-heavy nodes.
Postgres in a container on a 4 GB VPS runs with its defaults of 128 MB shared_buffers. I set it to 512 MB via command: postgres -c shared_buffers=512MB in compose and leave work_mem alone. Autovacuum is on by default and handles the churn from pruning.
Memory limits for the main process and task runners
NODE_OPTIONS and the main process heap
Node.js decides its own heap ceiling from the memory it sees, and in a container that is the host's memory, not the container's limit. So a 4 GB VPS with Postgres taking a gigabyte can end with n8n growing until the kernel kills something. The n8n memory docs point at --max-old-space-size, set through NODE_OPTIONS, and pairing it with a container limit keeps the failure inside n8n where it logs a heap error, with no silent OOM kill:
n8n:
image: n8nio/n8n:2.38.5
environment:
NODE_OPTIONS: "--max-old-space-size=2048"
deploy:
resources:
limits:
memory: 3g
The heap gets about two thirds of the container limit; the rest is Node's non-heap memory, buffers and the internal task runner process. A heap error shows in the log as a JavaScript heap out of memory stack and is a workflow problem to fix by chunking (below), not a reason to keep raising the number.
Code node limits on the task runner
Code nodes don't run in the main process anymore. n8n 2.x hands them to a task runner, which in internal mode is a child process of the main, with its own settings: N8N_RUNNERS_MAX_CONCURRENCY (5 tasks at once), N8N_RUNNERS_TASK_TIMEOUT (300 seconds before the runner kills the task and restarts) and N8N_RUNNERS_MAX_OLD_SPACE_SIZE (the runner's own heap in MB, empty by default). Two things follow. A Code node that used to run for ten minutes over a big array now dies at five unless you raise the timeout. And every item array you hand a Code node crosses a WebSocket to the runner and back, so $input.all() on 50,000 items is serialised twice per run, once each way, which is why a "fast" workflow sometimes takes minutes. N8N_RUNNERS_MAX_PAYLOAD caps that transfer at 1 GiB. Keep Code nodes small and put them after a filter, not before one.
N8N_CONCURRENCY_PRODUCTION_LIMIT in regular mode
N8N_CONCURRENCY_PRODUCTION_LIMIT is -1 by default, no cap. A 2 vCPU regular-mode instance that receives 80 webhooks in a minute starts 80 executions and they all slow each other down, memory included. Setting the limit to 10 on that instance means ten run and the rest wait their turn, so a burst gets processed at a steady rate and not all at once badly. Pick a number close to what the box handles in parallel without swapping, watch the executions list for runs whose start is delayed and raise it if the delay is longer than the webhook senders tolerate.
Binary data mode on a single instance
In regular mode N8N_DEFAULT_BINARY_DATA_MODE defaults to filesystem, and the files go to N8N_BINARY_DATA_STORAGE_PATH, which is binaryData under the user folder. Leave it there. database mode pushes every PDF and image through Postgres, which is the right call in queue mode where workers share no disk and the wrong one on a single server. Binary files are pruned with the executions they belong to.
Workflow design: batches, sub-workflows and HTTP Request options
The settings above raise the ceiling. Workflow design is what decides how often you hit it.
Loop Over Items batch size
The n8n memory documentation gives the plain example: process 200 rows per execution instead of fetching 10,000 at once. Loop Over Items (the node that replaced Split In Batches) with a batch size of 200 to 500, feeding an Execute Workflow node, keeps each pass small. The sub-workflow does the work and returns only its result, so the parent never holds the whole dataset. On our nightly invoice reconciliation this took peak main memory from 2.1 GB to under 700 MB with the same 30,000 rows, and I stopped tuning at that point.
HTTP Request batching and pagination
The HTTP Request node has a Batching option (items per batch and an interval between batches) and a Pagination option that follows a next-page value until the API runs out. Use both on any API that supports bulk endpoints or paging. Twenty requests of 500 records each cost far less memory than one request of 10,000 and far less time than 10,000 requests of one, and the batch interval doubles as rate limiting so you stop tripping the API's 429s and retrying.
Schedule collisions and GENERIC_TIMEZONE
Two facts. Schedule Trigger nodes fire in GENERIC_TIMEZONE, which defaults to America/New_York if you never set it, and every schedule set to "midnight" fires in the same second. Our WHMCS sync and the backup export both ran at 02:00 for a month and the sync lost the race every night, timing out on Postgres while pg_dump held the disk; moving the sync to 02:20 was the whole fix. Spread schedules across the hour, and in a Schedule Trigger "every 5 minutes" means at 00, 05, 10, so a dozen five-minute workflows all fire together too: offset them with a cron expression.
Measure the change with /metrics
Set N8N_METRICS=true and scrape /metrics before you change anything, because the Node.js default metrics it exposes (resident memory, event loop lag, heap size) are the only honest before-and-after you'll get, and docker stats is too coarse to show a heap that grows over an hour. The scrape config and the queue and workflow labels are in the n8n Prometheus and Grafana guide. If after all of this the event loop lag is still high with the database quiet, the box is CPU-bound, and a VPS with dedicated cores such as the Ryzen VPS line moves that ceiling further than more RAM does. One caveat: a Code-heavy instance notices core speed, an API-glue instance never does.

