The trap I see most teams fall into is treating the node catalog like a buffet. They search for the app they're integrating with, drop the dedicated node onto the canvas, and assume they're doing things the right way. It works in the demo. Then they hit production and discover the node doesn't expose the API parameter they need, or the auth flow is broken, or the output shape requires three more nodes just to clean up.
The workflow becomes a brittle chain of workarounds because they started with a node instead of starting with the data.
Two buckets. Top of Mind: general-purpose nodes I use weekly — auth-flexible, data-agnostic, stable across n8n versions. Avoid Until Necessary: nodes that signal a design issue, a workaround, or a category error in how the workflow is architected.
This field guide is how I think about that split.
If I had to rebuild every production workflow I've ever written, six nodes would do most of the heavy lifting: HTTP Request, Webhook, Schedule Trigger, IF, Edit Fields, and Code. Everything else is either a convenience or a liability.
The HTTP Request node is the most versatile tool in n8n. I use it for REST APIs that lack a native node, for native nodes that lag behind an API's latest version, and for endpoints where I need control over headers, query parameters, or pagination that the dedicated node abstracts away poorly.
It handles every authentication pattern I encounter. Header Auth for API keys. OAuth2 for SaaS platforms. Basic Auth for legacy internal services. Custom Auth when the provider demands something weird. I can batch requests to respect rate limits — set items per batch and a millisecond interval — which saves me from building manual loops. And when an API paginates, I don't wire up recursive workflows anymore. I enable pagination under Options, pick offset-based, cursor-based, or URL-based strategy, and let the node merge pages into a single output array.
I always enable Full Response when I need access to response headers, like X-RateLimit-Remaining or a cursor token. Otherwise the node returns only the parsed body. For file downloads, I switch the response type to Binary and pass the property downstream.
The Ignore SSL Issues toggle is for local development only. In production, install the correct CA bundle on the server.
The only time I prefer a dedicated node over HTTP Request is when that node handles complex OAuth refresh logic or exposes operations as clean dropdowns that save real time. But the moment the native node is missing a field I need, I switch to HTTP Request immediately. I don't wait for the native node to catch up.
Every workflow that reacts to external events starts here. The Webhook node exposes a URL path, accepts GET, POST, or other methods, and hands me the body, headers, query, and params as structured JSON.
The test URL (/webhook-test/...) only works while you're actively listening in the
editor. The production URL (/webhook/...) only works when the workflow is saved and
active. I've debugged more "the webhook isn't firing" issues that turned out to be an
external service pointing at the test URL than I care to count.
I configure response mode based on what the caller needs:
For production, I never leave a webhook open. I enable Header Auth at minimum. Path collisions are another silent killer: if two active workflows share the same path and method, only one receives the request. On self-hosted instances, I verify the WEBHOOK_URL environment variable is set to the public-facing address.
Not every workflow needs an event. Some just need to wake up and check the world. The Schedule Trigger is cron, plain and simple. I use it for daily reports, stale-data cleanup, and polling endpoints that don't offer webhooks. It's boring, and that's the point.
Reliability in automation often looks like a boring node firing at 06:00 every morning.
Most n8n tutorials reach for the IF node too early. It's the right tool maybe 30% of the time I see it used. For binary routing — "status equals paid" versus everything else — it's fine. For null checks before a risky operation, it's fine.
Where it breaks down is complex logic. Chaining three IF nodes to simulate an AND/OR mix produces a visual mess that's harder to read than three lines of JavaScript. When I need to route on compound conditions, I switch to an expression-based boolean inside a single IF node, or I handle the logic in a Code node upstream and output a clean routing flag.
A field contains the string "42" and the workflow compares it as a Number greater than
10. The comparison fails silently, and every item routes to the False branch. Cast
explicitly with {{ Number($json.count) }} when the source is unreliable.
The IF node has two outputs: True and False. Both don't need to be connected. If I only care about the match, I leave False dangling and those items drop silently. That's a feature, not a mistake.
Edit Fields — the node formerly known as Set — is how I reshape data without writing code. I rename fields to match downstream APIs, flatten nested objects for spreadsheets, strip sensitive fields before logging, and add computed timestamps.
The Keep Only Set toggle is powerful and dangerous. Enabled, it drops every field I don't explicitly define. This is exactly what I want when I'm passing data to a logging endpoint and need to scrub passwords or tokens. Disabled, it merges my new fields onto the existing item. I always verify the output after enabling Keep Only Set, because downstream nodes will fail if they expect a field that just vanished.
Dot notation in field names creates nested objects automatically. Setting user.address.city builds the hierarchy without a Code node. The expression toggle next to each value field is easy to miss — if the output shows literal {{ $json.name }} instead of the resolved value, the toggle isn't active.
The Code node runs JavaScript (or Python via Pyodide) inside the workflow. I use it when the built-in nodes can't express the transformation I need: aggregating across items, filtering arrays by complex rules, or reshaping deeply nested JSON that would take six Edit Fields nodes to untangle.
If you have more than three Edit Fields nodes in a row performing the same type of transformation, that's a Code node. Three sequential field mappings means the data structure has changed enough that code is clearer and more maintainable.
The Code node has two run modes:
The most common error I see is mixing the two: calling $input.item in "All Items" mode returns undefined, and calling $input.all() in "Each Item" mode works but is inefficient.
The return format trips people up constantly. The node must return an array of objects, each wrapped in a json key:
return [{
json: {
name: "Alice",
processed: true,
total: items.reduce((sum, item) => sum + item.json.amount, 0)
}
}];
Built-in libraries save me from importing dependencies. Luxon handles timezone math. Lodash handles object manipulation. JMESPath queries complex JSON. For external HTTP calls from within code, this.helpers.httpRequest() is available, but async/await is required — forgetting await returns a Promise object instead of data.
The real decision is when to stop clicking in the UI and start writing code:
if/else logic on a field's content → Code.Default to Edit Fields for cosmetic changes. The moment you're chaining them or writing expressions so complex they span three lines, switch to Code. Your future self will thank you.
Data storage in n8n workflows is a choice between convenience and correctness.
When a workflow needs to remember state, enforce structure, or handle concurrency, I use PostgreSQL. The node offers Execute Query for raw SQL, plus Insert, Update, Delete, and Upsert operations for standard CRUD.
I almost always use parameter binding. Never concatenate user input into a SQL string. I write SELECT * FROM orders WHERE customer_id = $1 AND status = $2 and pass values through Query Parameters. This isn't paranoia; it's hygiene.
Upsert is my default for sync workflows. Reading from an external API and mirroring the data into Postgres? Set the key column, choose Upsert, and the workflow becomes idempotent. I can run it every hour without creating duplicates.
I watch connection limits. Each n8n execution opens a database connection. On busy instances, I exhaust max_connections fast. In production, I put PgBouncer in front of Postgres.
Google Sheets is the node I use most often and trust the least. It's excellent for human-readable reporting and a terrible database.
Google enforces 60 requests per minute per user per project. A workflow that appends rows in a loop or reads a large sheet on a tight schedule will hit that ceiling. The Append and Update operations match fields to columns using the header row text. If the header says First Name with a trailing space and my expression outputs First Name without one, the value is silently dropped.
Read Rows stops at the first completely empty row. If my data has gaps, the output truncates without warning. For sheets over 10,000 rows, the node slows down enough that I should have used Postgres months ago.
My rule: Sheets are for output that humans read. Postgres is for state that workflows read. Violating this rule always costs more in maintenance than it saves in setup time.
I use Slack for one thing: telling humans what happened. The node supports rich Block Kit formatting through OAuth2, or simple fire-and-forget messages via webhook credentials.
Webhook credentials are limited: send only, no channel lists, no file uploads, no message updates. For anything beyond a basic text alert, I create a Slack App, request the chat:write and channels:read scopes, and use OAuth2.
If you're posting in a loop, add a Wait node with a one-second delay, or use SplitInBatches
with an interval. The bot must be invited to the channel with /invite @BotName or you
get not_in_channel errors. Even when using Block Kit, the Text field is required
because Slack uses it as a notification preview and accessibility fallback.
One hard rule: I never use Slack as an event bus or a state store. "Send a Slack message and check if anyone reacted" is not a workflow pattern; it's a support ticket waiting to happen.
The GitHub node is for DevOps workflows: auto-labeling issues, creating tickets from webhooks, tracking releases, and posting stale-issue reports. It's not a general-purpose project management tool inside n8n.
The repo scope on classic personal access tokens is broad — read and write on every repository the user can access. I prefer fine-grained tokens scoped to specific repos. Labels must already exist before I can apply them via the Create Issue operation. Pagination defaults to 30 items per page; for large repositories, enable Return All, but stay mindful of the 5,000 requests-per-hour rate limit.
For simple read-only operations where I need custom headers or GraphQL, I often skip the native node and use HTTP Request with a GitHub token.
These are nodes I treat like a fire alarm: I only touch them when something is already wrong.
The Google Forms Trigger node polls the Forms API for new submissions. The problem is the output structure. Answers are keyed by internal question IDs — hex strings like 6f2b3c1a — not by the question text. Every workflow using this trigger needs an immediate downstream Code or Edit Fields node just to map IDs to question labels. It also polls, which means delay and quota consumption.
I avoid this node by using the Google Sheets Trigger on the linked response sheet instead. The sheet has readable column headers automatically. The data structure is flat. The auth is the same.
I avoid dedicated nodes that are thin wrappers around a single REST endpoint. If removing the node name and replacing it with HTTP Request costs me zero extra configuration, the node is dead weight. App-specific nodes that lack pagination controls, miss recent API parameters, or return output shapes that require immediate cleanup are liabilities.
Signals that you should reach for HTTP Request instead:
Count the nodes. If more than half are app-specific integrations, try rebuilding one core path using HTTP Request, Edit Fields, and Code. You'll likely end up with fewer nodes and more control.
If you're using Sheets to store state — lookups, queues, or transactional data — move one workflow to PostgreSQL this week. Use Upsert for idempotency, parameterise every query.
Are they using Header Auth? Are the external services pointing at production URLs, not test URLs? Fix one open endpoint.
The next time you find yourself adding a fourth Edit Fields node in a row, stop. Open a Code node.
The clarity you gain is worth the five minutes it takes to write the JavaScript.