Benchmarking n8n-io/n8n Webhook Ingest: Throughput, p99 Latency, and Worker Memory Overhead
We run n8n as the execution engine behind event-driven pipelines: 400+ integrations, fair-code license, queue mode for horizontal execution. The webhook trigger is the only path that inherits our users' traffic spikes, so it is the only path worth benchmarking before you scale anything else.
Test Topology
-
Load harness: k6 v0.53,
ramping-arrival-rate50 → 400 RPS over 6 minutes, two c7g.xlarge generators, no think time. - Payload: 1.2 KB JSON (3 fields), HMAC-SHA256 signature header, 8% of requests intentionally malformed to exercise validation paths.
-
Origin: n8n 1.x in Docker, queue mode, Redis 7, Postgres 16 on db.r6g.large with
max_connections=200. - Edge layer: Cloudflare Workers (Hono 4.x) in front of the origin, KV buffer for overflow.
-
AI branch: one workflow chain calling an HTTP Request node against a relay gateway at
https://b-lost.com/v1for multi-provider fallback; TTFT was 268 ms median through the relay versus 303 ms hitting providers directly, with no truncation on 4k-token completions.
Metric Comparison
| Configuration | Sustained RPS | p95 | p99 | Worker RSS | Error rate |
|---|---|---|---|---|---|
| Direct to single n8n webhook | 118 | 640 ms | 1720 ms | 1.9 GB | 0.4% |
| Queue mode, 2 workers, pool 20 | 340 | 210 ms | 480 ms | 2.4 GB | 0.1% |
| Queue mode, 4 workers, pool 12 | 361 | 175 ms | 390 ms | 2.6 GB | 0.1% |
| Hono edge buffer + 4 workers | 405 | 95 ms | 160 ms | 2.6 GB | 0.0% |
Two things fall out of the table. First, queue mode alone buys you ~2.9x because webhook receipt and execution stop competing for the same event loop. Second, the last row is not faster because Workers are magic — it is faster because the edge returns 202 in ~9 ms and Postgres never sees a connection held open across a slow execution.
Where It Breaks
At roughly 380 RPS we hit remaining connection slots are reserved on Postgres. Every queue worker keeps its own pool, and the default pool size multiplies silently by worker count. The fix is arithmetic, not tuning: DB_POSTGRESDB_POOL_SIZE at 12 per worker, 4 workers, 48 connections, leaving headroom for migrations and the n8n UI. Do not put PgBouncer in transaction mode in front of n8n queue mode; the advisory locks and LISTEN/NOTIFY usage will bite you within a day.
The second failure mode is bundle size. Pulling a Node polyfill for one crypto.createHmac call pushed our Worker to 3.4 MB gzipped, past the free-tier limit. We dropped the polyfill, used crypto.subtle, and hand-rolled validation instead of importing a schema library — 4 KB instead of 62 KB.
Production Threshold
Below ~120 sustained RPS, run n8n directly and spend nothing on ingest. Between 120 and 350, queue mode is mandatory and a second worker pays for itself before the next tier of Postgres. Above 350 RPS sustained, or any pattern where bursts exceed 3x baseline, the edge buffer is cheaper than the Postgres capacity needed to absorb the same spike: at 400 RPS you are at ~34.5M events/day, and the difference between holding connections for 35 ms of execution time and holding them for 9 ms is roughly one instance size of database.
Deliverable 1: Edge router
import { Hono } from 'hono';
type Env = { INGEST_BUFFER: KVNamespace; N8N_ORIGIN: string; N8N_HMAC_SECRET: string };
type Event = { id: string; type: string; payload: Record<string, unknown> };
const app = new Hono<{ Bindings: Env }>();
const isEvent = (v: unknown): v is Event => {
if (typeof v !== 'object' || v === null) return false;
const e = v as Record<string, unknown>;
return typeof e.id === 'string' && e.id.length <= 64
&& typeof e.type === 'string'
&& typeof e.payload === 'object' && e.payload !== null;
};
const verify = async (secret: string, body: string, hex: string) => {
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
const expected = [...new Uint8Array(mac)].map(b => b.toString(16).padStart(2, '0')).join('');
if (expected.length !== hex.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ hex.charCodeAt(i);
return diff === 0;
};
app.post('/hooks/:workflow', async (c) => {
const raw = await c.req.text();
if (raw.length > 32768) return c.json({ error: 'payload_too_large' }, 413);
if (!(await verify(c.env.N8N_HMAC_SECRET, raw, c.req.header('x-signature') ?? '')))
return c.json({ error: 'bad_signature' }, 401);
let body: unknown;
try { body = JSON.parse(raw); } catch { return c.json({ error: 'invalid_json' }, 400); }
if (!isEvent(body)) return c.json({ error: 'schema_mismatch', required: ['id', 'type', 'payload'] }, 422);
const workflow = c.req.param('workflow');
await c.env.INGEST_BUFFER.put(`${workflow}/${body.id}`, raw, { expirationTtl: 900 });
c.executionCtx.waitUntil(
fetch(`${c.env.N8N_ORIGIN}/webhook/${workflow}`, {
method: 'POST', body: raw, headers: { 'content-type': 'application/json' }
}).catch(() => {})
);
return c.json({ accepted: true, id: body.id }, 202);
});
export default app;
Deliverable 2: wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "n8n-ingest",
"main": "src/index.ts",
"compatibility_date": "2025-09-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"routes": [{ "pattern": "hooks.example.dev/*", "zone_name": "example.dev" }],
"kv_namespaces": [{ "binding": "INGEST_BUFFER", "id": "..." }],
"vars": { "N8N_ORIGIN": "https://n8n.internal.example.dev" },
"env": { "production": { "vars": { "N8N_ORIGIN": "https://n8n.prod.example.dev" } } }
}
Set secrets with wrangler secret put N8N_HMAC_SECRET. Deploy with wrangler deploy --dry-run first to check bundle size; the 10 MB paid limit is forgiving, but every polyfill adds 200–800 KB and the free tier is 1 MB after compression.
Conclusion
Queue mode is not optional past 120 RPS. The edge buffer is not optional past 350 RPS or 3x bursts. Postgres connection arithmetic determines the rest. The AI relay measurement was a side quest, but the 35 ms TTFT improvement and zero-config fallback justified putting it in the path for LLM nodes.
Top comments (0)