Tooling
n8n nodes we don't trust in production: a cheatsheet
The HTTP Request node retries silently. The Code node has no tests. The AI Agent node burns budget. Here is what we replace, and what we keep.

A client's invoice-chase workflow ran clean on Friday. Sunday morning, 200 dunning emails went out to clients who had already paid. The HTTP Request node hit their billing system, got back a 200 with an empty body, n8n parsed that as an empty array, and the "in the paid list?" filter said no for everyone. The accounting team spent Monday writing apology emails.
That workflow had been in production for six months. The bug had been latent the whole time. It only showed up when the billing API got rate-limited and started returning empty 200s instead of 429s.
n8n is a useful tool. We use it. The visible parts of a pipeline (the triggers, the simple data moves, the things an operations lead wants to see in a diagram) belong there. The brittle parts do not. This is the cheatsheet we hand to clients when we audit an existing instance.
The four nodes we treat as drafts
HTTP Request
Default timeout is 300 seconds. Retries are off by default and silent when on. There is no idempotency-key field. There is no circuit breaker. The response parser will happily turn an empty 200 into an empty array, as above. If the upstream API has a quirk (and they all do), the HTTP Request node will not protect you from it.
The node is fine for one-shot calls into a tame internal API you control. It is not infrastructure.
Code (JavaScript)
The Code node now runs in isolated-vm. It used to run in vm2, which was deprecated in 2023 after a series of sandbox escapes (CVE-2023-29017 was the loud one). The sandboxing question is now closed. The operational question is not.
Code in a Code node has no version control beyond the workflow JSON, no tests, no PR review, no linter, no types. The diff that adds a bug is invisible until someone exports the workflow and reads 4,000 lines of JSON. We have done that exercise. It is not fun.
AI Agent and LangChain chain nodes
Token spend is unbounded. There is no per-run budget cap inside the node. Prompts live in the workflow JSON, so prompt changes have no audit trail. JSON parsing on model output fails silently. The "memory" sub-nodes hold conversation state in the n8n database, which is not what an n8n database is built for.
For prototyping a workflow that calls a model once, fine. For an agent that runs 1,000 times a day on customer email, no.
Wait and Schedule Trigger combos
A Wait node holds workflow state in the n8n execution table. A workflow that waits seven days for a "did the customer reply" check leaves an open execution row for seven days. Restart the n8n container, lose the in-memory bits. The Schedule Trigger does not handle DST cleanly on hourly crons. We have seen workflows fire twice on the spring switch.
If your n8n instance has more than 50 active executions in the "waiting" state at any given moment, you are using n8n as a durable execution engine. It is not one.
The four replacements we wire in
None of these are exotic. All are open source, all run on a small VPS, all live next to the n8n instance and get called from it.
Hono service for HTTP boundaries
Every external HTTP call that matters gets a thin Hono wrapper. The wrapper owns the timeout, the retry policy, the idempotency key, and the response sanity checks. n8n calls one endpoint per integration, not the raw upstream.
import { Hono } from 'hono'
const app = new Hono()
app.post('/billing/check-paid', async (c) => {
const { invoiceId } = await c.req.json()
const res = await fetch(`${BILLING_URL}/invoices/${invoiceId}`, {
headers: { 'X-Idempotency-Key': invoiceId },
signal: AbortSignal.timeout(8_000),
})
if (!res.ok) throw new Error(`billing ${res.status}`)
const text = await res.text()
if (!text) throw new Error('billing returned empty body')
const data = JSON.parse(text)
return c.json({ paid: data.status === 'paid' })
})
export default app
The Sunday-morning dunning bug above does not happen here. An empty body throws. A throw becomes an error in n8n, which has a visible Error Workflow attached. The operations lead gets a Slack ping at 9am instead of 200 apology emails at 11am.
BullMQ for fan-out and retries
BullMQ on Redis handles anything that needs to retry, dedupe, or run in parallel. n8n triggers the job by hitting an HTTP endpoint. The endpoint enqueues. The worker does the work.
import { Queue } from 'bullmq'
const reminders = new Queue('reminders', { connection: redis })
await reminders.add(
'send',
{ invoiceId, to: customer.email },
{
attempts: 5,
backoff: { type: 'exponential', delay: 1_000 },
jobId: `reminder:${invoiceId}`, // dedup, the same invoice cannot enqueue twice
},
)
The jobId line is the one that matters. It is also the line a Code node would not have.
Inngest for durable workflows
The "wait seven days then check" pattern moves to Inngest. The open source build is Apache 2.0 and runs on a single Postgres plus a single Go binary. We deploy it next to n8n.
import { Inngest } from 'inngest'
const inngest = new Inngest({ id: 'invoice-chase' })
export const chase = inngest.createFunction(
{ id: 'chase-overdue', retries: 4 },
{ event: 'invoice/overdue' },
async ({ event, step }) => {
const paid = await step.run('check-paid', () =>
fetch(`${API}/billing/check-paid`, {
method: 'POST',
body: JSON.stringify({ invoiceId: event.data.id }),
}).then((r) => r.json()),
)
if (paid.paid) return { skipped: true }
await step.sleep('wait-3d', '3d')
const stillUnpaid = await step.run('recheck', () => checkAgain(event.data.id))
if (stillUnpaid) await step.run('send-reminder', () => sendEmail(event.data))
},
)
Each step.run is a checkpoint. Restart the worker, the function resumes from the last completed step. The seven-day wait is not in n8n's execution table, it is in Inngest's state store, which is what that store is for.
A small function service for everything else
For the work that used to live in a Code node, we keep a single repo of TypeScript functions. Each is one HTTP endpoint. Each has tests. Each ships through CI. The workflow calls them like any other HTTP integration.
This sounds heavier than it is. The first function takes an afternoon. The fifth takes ten minutes. The benefit is that nobody needs to export workflow JSON to review a logic change.
What stays in n8n
The triggers stay. The flow diagram stays. The if-this-then-that branching stays. The "look at the last seven runs and see what happened" UI stays. n8n is good at being the visible layer.
The pattern is the same one you would apply to any low-code tool. Use it for the parts that benefit from being visible. Move the parts that need to be correct, fast, or auditable behind it.
When we rebuilt the invoice-chase AI agents for a Dutch accounting client, the workflow JSON shrunk from 87 nodes to 11. The 11 left are the ones the finance lead actually wants to look at. Everything else lives in a 600-line TypeScript repo with tests, behind a Hono service, queued through BullMQ.
Five-minute audit you can run today: open your n8n instance, filter the Executions view by status "error" for the last 30 days, and count how many failures live in HTTP Request, Code, or AI Agent nodes. That count is your replacement priority list.
Key takeaway
n8n is the dashboard, not the engine. Move the HTTP, code, agent, and long-wait nodes behind a small service you control.
FAQ
Is n8n a bad choice for production?
No. It is a good choice for the visible orchestration layer. The problem is when the brittle parts (HTTP calls, model calls, long waits, untested code) live inside the workflow instead of behind it.
Why Inngest over Temporal?
Both work. Inngest has a lighter footprint and a faster onboarding for teams that already write TypeScript. Temporal wins for polyglot teams or when you need stronger guarantees around exactly-once semantics.
Do you still use the n8n Code node at all?
Yes, for one-line transforms (rename a field, map a status string). Anything longer than ten lines or that needs a test goes into the function service repo.
What about the AI Agent node for simple prompts?
Fine for a prototype. As soon as the prompt matters to a customer or costs money at volume, move it to a service where prompts are versioned, costs are capped, and outputs are validated.