n8n SplitInBatches Node:
process big lists without blowing up your workflow.
I blew through a rate limit in production because I let n8n fire 400 API calls in parallel. Zero warning. Workflow succeeded. The API returned errors for 80% of items and I had no idea until I checked the downstream data and it was mostly empty. SplitInBatches is the node that makes "batch of 400" into "20 at a time with a pause." The node is simple. The decision to add it is the part people skip.
- What SplitInBatches actually does
- Core setup: wiring it in, batchSize, and how the loop terminates
- Pattern 1: large list with API calls (rate limit control)
- Pattern 2: sending bulk messages in chunks
- The gotcha: why you're only seeing the last batch's data
- How to aggregate results after the loop
- Using a Code node inside the loop
- When NOT to use SplitInBatches
I have a workflow that enriches every new lead that hits my CRM. When I tested it on a backfill of 400 contacts at once, it instantly hit Clearbit's rate limit and half the records came back empty. SplitInBatches with a batch size of 10 and a Wait node between batches fixed it in about four minutes of work. That's the whole story.
What SplitInBatches actually does (and how it saves your rate limits)
The n8n SplitInBatches node takes an array of items and cuts it into smaller chunks, then sends those chunks through your workflow one at a time in a loop. When each batch finishes processing, it loops back to SplitInBatches for the next chunk. When all chunks are done, the loop ends and the workflow continues downstream.
That's it. No magic. The reason this node exists is that n8n processes all items in parallel by default. If you have 500 items hitting an HTTP Request node, n8n will try to fire 500 requests essentially at once. Most APIs can't handle that. Rate limits exist for a reason, and "429 Too Many Requests" is the universe telling you that you forgot SplitInBatches.
The three situations where this node earns its keep:
- API rate limits โ you're hitting an API that caps you at 10 requests per second, or 100 per minute, or some other constraint that a mass-fire approach obliterates
- Large arrays that overwhelm downstream nodes โ some nodes get weird with very large item counts; batching keeps execution predictable
- Controlled flow with deliberate pacing โ bulk sends (email, Slack, Telegram) where you want to send 50 at a time with a pause in between, not 5,000 in a single burst
Think of it like a conveyor belt with a speed limiter. The items still all get processed. They just go through in groups instead of all at once, so the thing at the end of the belt doesn't get crushed.
Core setup: wiring it in, batchSize, and how the loop terminates
Here's the basic anatomy of a SplitInBatches workflow:
[Trigger / Data source]
โ
[SplitInBatches] โโโโโโโโโโโโโโโโโโโโ
โ (loop branch) โ
[Process items: HTTP Request, โ
Code node, Airtable write, etc.] โ
โ โ
[Wait node โ optional, rate control] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ (done branch, when no items left)
[Continue to next nodes]
The node has one key setting: batchSize. This controls how many items go through on each pass of the loop. The default is 10. If you set it to 50, the node sends 50 items to the next node in line, waits for that branch to finish, then loops back and sends the next 50.
SplitInBatches has two output connectors:
- loop โ this fires when there are still items to process; wire your processing nodes here
- done โ this fires once when all items have been processed; wire everything that should happen after the full loop finishes here
The loop terminates automatically. You don't need to write any exit logic. When SplitInBatches runs out of items to send, it fires the done connector and stops looping. If your list has 100 items and your batchSize is 10, the loop runs exactly 10 times and then exits.
Obvious in retrospect, catastrophic in the moment. If you accidentally close the loop by connecting done back to something that re-triggers SplitInBatches with fresh data, you'll create an infinite loop. The workflow will run forever and you'll spend 20 minutes confused about why it never finishes.
Pattern 1: Processing a large list with API calls
This is the one I use most. You have a list of contacts, records, or IDs. You need to make an API call for each one. The API has a rate limit that your naive workflow already violated at least once. SplitInBatches is the fix.
Say you pulled 500 contacts from Google Sheets and you want to enrich each one with data from Clearbit or Hunter.io. The setup looks like this:
Google Sheets (read all 500 rows)
โ
SplitInBatches [batchSize: 5]
โ loop
HTTP Request โ GET https://api.hunter.io/v2/email-finder
?domain={{ $json.company_domain }}
&first_name={{ $json.first_name }}
&last_name={{ $json.last_name }}
&api_key={{ $vars.HUNTER_KEY }}
โ
Set node (map enriched fields back to clean shape)
โ
Wait node [1 second]
โ
(loops back to SplitInBatches)
โ done
Google Sheets (write enriched records back)
The Wait node between batches is the important piece here. Hunter.io allows roughly 25 requests per second on paid plans, but lower on free. With a batchSize of 5 and a 1-second wait, you're doing 5 requests per second โ well under any reasonable limit. You can tune both numbers based on your API's actual limits.
Without SplitInBatches, n8n would fire all 500 HTTP requests in near-parallel. You'd hit the rate limit around request 26, get a flood of 429 errors, and get back a mix of good and empty data with no clean way to identify which ones failed. With SplitInBatches, every request goes through in a controlled sequence. If something fails, it's easy to see exactly which batch caused it.
Choosing the right batchSize for API calls
There's no universal number. It depends on the API.
- If the API rate limit is X requests per second, set batchSize below X and add a 1-second Wait between batches
- If the rate limit is per minute (e.g. 100/min), use batchSize of 10 with a 6-second Wait โ that gives you 100 per minute exactly
- If the API is slow and you just want to avoid overloading it, batchSize of 1 with a short Wait is perfectly valid, even if it feels inefficient
Start conservative. 5โ10 with a 1-second wait handles most APIs without drama. You can increase it once you know the limits.
Pattern 2: Sending bulk messages in chunks
This one comes up when you're doing outreach blasts โ Telegram channel updates, Slack DMs to a list of users, email sequence triggers, or any situation where you're sending the same kind of message to a large list of recipients.
Sending bulk Slack messages to 200 people all at once will probably hit Slack's tier 1 rate limit (1 request per second per method) almost immediately. More importantly, if something fails halfway through, you have no easy way to know who got the message and who didn't.
The SplitInBatches approach:
Airtable (fetch list of 200 user records)
โ
SplitInBatches [batchSize: 10]
โ loop
HTTP Request โ POST https://slack.com/api/chat.postMessage
body: {
"channel": "{{ $json.slack_user_id }}",
"text": "Hey {{ $json.first_name }}, wanted to reach out..."
}
โ
Set node (capture send status + timestamp)
โ
Airtable (update record: sent = true, sent_at = now)
โ
Wait node [2 seconds]
โ
(loops back)
โ done
Slack (post summary: "Sent to 200 users, X failed")
The write-back to Airtable after each batch matters a lot in practice. If your workflow dies midway through for any reason โ timeout, API error, n8n restart โ you want to know exactly where it stopped. Marking each sent record as sent_at allows you to restart the workflow and skip records that already got the message, instead of blasting everyone again.
Write back status after each batch, not just at the end. The done branch is fine for summaries, but if something crashes before you get there, you'll be grateful for per-batch checkpoints.
The gotcha everyone hits: only seeing the last batch's data
This is the one that trips up everyone the first time they use SplitInBatches. You finish the loop, the done branch fires, you try to use the data from inside the loop โ and you only get the results from the very last batch. The other 490 items vanished.
Here's why: SplitInBatches does not automatically aggregate results across loop iterations. Each time the loop runs, it processes a batch and the output of those processing nodes exists within that single iteration. When the loop runs again for the next batch, that output is gone โ replaced by the new batch's output. By the time you exit through the done connector, only the most recent iteration's data is accessible.
If you process 500 contacts in batches of 10, the done branch sees exactly 10 items โ whatever came out of the last batch. The other 490 were processed but their outputs are not carried forward by default.
The workflow runs, looks like it works, you check the output and see 10 records instead of 500. The instinct is to assume SplitInBatches is broken. It's not. You just need to aggregate manually. Here's how.
How to aggregate results after the loop
There are two main ways to collect results across all batches, depending on your setup.
Option 1: Write results to persistent storage inside the loop
The simplest approach. Instead of trying to aggregate in-memory across loop iterations, just write each batch's results to somewhere persistent โ a Google Sheet, Airtable, a database, a file, a Supabase table โ inside the loop branch. When the done branch fires, your complete dataset is already in storage.
SplitInBatches [batchSize: 20]
โ loop
[HTTP Request โ enrich contact]
โ
[Set node โ map enriched fields]
โ
[Google Sheets โ append rows] โ writes each batch's results
โ done
[Google Sheets โ read all rows] โ now you have everything
This is the approach I use in most production workflows. It's idempotent, crash-safe, and easy to debug. If the loop fails on batch 7, batches 1โ6 are already in your sheet and you know exactly where to restart.
Option 2: Use a Merge node to collect in-memory results
If you don't want to write to storage during the loop and prefer to collect everything in memory before doing a single write, you can use the Merge node in "Append" mode inside the loop. The pattern is a bit more involved.
n8n's Merge node with "Combine" or "Append" mode can concatenate outputs across multiple executions, but you need to be intentional about how you wire it. The key is feeding the Merge node from both the current batch's output and the previous merged result, using a static data store or the $workflow.staticData trick to accumulate items.
Honest take: for most batch workflows, persistent storage (Sheets, Airtable, Postgres, etc.) is cleaner than in-memory accumulation. The Merge approach gets fragile if the workflow fails midway and there's nothing to restart from. Use persistent writes unless you have a specific reason not to.
Option 3: Use a Code node to build a running list in static data
This is the fancy version. More on it in the next section, but the idea is using $workflow.staticData to accumulate results across loop iterations inside a Code node. It works, but it requires care around resetting state between workflow runs.
Using a Code node inside the loop
A Code node inside the SplitInBatches loop is useful for two things: tracking progress and building a running accumulated list.
Track batch progress
const sd = $workflow.staticData;
// Initialize counters on first run
if (!sd.batchCount) {
sd.batchCount = 0;
sd.processedItems = 0;
}
sd.batchCount++;
sd.processedItems += items.length;
console.log(`Batch ${sd.batchCount}: processed ${items.length} items (${sd.processedItems} total)`);
return items;
This gives you a running count in the execution logs so you can see exactly how far through the loop you are without having to count batches manually. When debugging a workflow that's processing 1,000 items, being able to see "Batch 47: 470 total processed" is the difference between patience and confusion.
Accumulate results in static data
const sd = $workflow.staticData;
// Initialize the accumulator on first batch
if (!sd.allResults) {
sd.allResults = [];
}
// Add current batch results to the running list
for (const item of items) {
sd.allResults.push({
email: item.json.email,
name: item.json.name,
enriched_company: item.json.enriched_company,
enriched_at: new Date().toISOString()
});
}
// On the last item, make the full list available as output
// (wire this code node to your done-branch aggregation step)
return [{ json: { results: sd.allResults, total: sd.allResults.length } }];
Static data persists between workflow executions. If you accumulate results in $workflow.staticData, add a reset at the start of the workflow (before SplitInBatches) that clears sd.allResults = []. Otherwise your second run will append to the first run's results and you'll get a growing blob of stale data.
Want this pattern in a real workflow you can import?
The n8n Starter Pack includes batch-processing workflows pulled from a live business stack โ with rate limit control, per-batch checkpointing, and error handling already built in. Not toy examples.
See Google Workspace MCP โOne-time $97 ยท Instant download ยท 30-day money-back guarantee
When NOT to use SplitInBatches
SplitInBatches solves a specific problem: controlled chunking of large arrays with rate-limit-aware looping. It is not a general-purpose loop node. There are situations where it's the wrong tool.
When you want to do something simple to every item
n8n processes all items through every node automatically. If you have 50 items coming in and you just want to transform, filter, or map each one, you don't need SplitInBatches. The Set node, Code node, Filter node, and most others already operate on each item in the array. Wrapping that in a SplitInBatches loop adds unnecessary complexity and makes the workflow harder to read.
When you need Loop Over Items
n8n has a newer node called Loop Over Items (sometimes shown as "Loop" in the node panel). For many use cases, it's cleaner than SplitInBatches. Loop Over Items processes one item at a time by default, has a more intuitive setup for simple sequential processing, and its loop/done output structure is similar but the mental model is easier to follow for beginners.
The rough guide: if you want to process a large array in controlled batches for rate-limit reasons, use SplitInBatches with an explicit batchSize. If you just want to iterate over items one by one with a sequential node chain (no rate control needed), Loop Over Items is often less confusing.
In practice both work. I reach for SplitInBatches when the batch size number matters to me โ i.e. when rate limits are the actual constraint I'm solving for.
When you have a small number of items
If you have 15 items and no rate limit concern, SplitInBatches is overkill. You're adding loop complexity and a done-branch aggregation problem for no reason. Just let the items flow through normally.
When the ordering of results matters and you're not tracking it
SplitInBatches processes batches sequentially, so the order is deterministic. But if you're writing results to a database inside the loop and your write step is async or occasionally slow, the final stored order might not match the input order. If ordering matters for your use case, add a sort step after the done branch, or include a sequence index in each item before the loop starts.
Rate limits or large array control โ SplitInBatches with batchSize. Simple per-item iteration โ Loop Over Items. Transformation with no looping needed โ just run through the node directly.
The patterns that actually hold up in production once volume gets real
After running batch workflows against a handful of APIs with real data, a few things consistently matter:
Always add a Wait node in the loop branch. Even if you think you're under the rate limit, a short wait prevents edge cases where a batch takes 50ms and suddenly you're firing more per second than you intended. 500ms to 2 seconds between batches costs almost nothing in clock time for most workflows.
Write per-batch to persistent storage. Do not rely on in-memory accumulation through done unless you have a specific reason. Crashes mid-loop are more common than you'd think, especially on long-running batch jobs against flaky APIs. If each batch writes its output, the workflow is recoverable.
Include a sequence index in each item before the loop. Before you hit SplitInBatches, add a Code node that stamps each item with its original position in the array. This makes it trivial to spot gaps, sort the final output, and debug which batch handled which records.
return items.map((item, index) => ({
json: {
...item.json,
_seq: index,
_total: items.length
}
}));
Log what fails, not just what succeeds. If an HTTP Request inside the loop gets a 429 or 500, n8n will throw an error and stop the batch unless you have error handling enabled. Use the Continue On Error setting on the HTTP Request node (or add an Error Branch) so one bad API response doesn't kill the whole loop. Log the failures separately and you'll have a clean list of items to retry.
SplitInBatches [batchSize: 10]
โ loop
HTTP Request [Continue On Error: true]
โ
IF node: $json.error exists?
โ true โ false
[Log failure to [Process success
Airtable/Sheets] normally]
โ โ
Wait [1 second] Wait [1 second]
โโโโโโโโโโโโโโโโโโโโโโโ
(both branches merge back before looping)
This is the pattern I run in production. Failures get logged with the item data, successes get processed, and nothing stops the loop mid-run because one API had a bad moment.
Pairing SplitInBatches with Merge: how to collect results from every batch into one output
If you do want to use the Merge node for in-memory aggregation (instead of persistent storage writes), the cleanest approach is to use it outside the loop, not inside. Wire your loop branch through processing, then wire the loop output to a Merge node that's also connected from the done branch trigger. Use the Merge node's "Append" or "Combine" mode.
The challenge is that the Merge node needs to know when all batches are done before it can output the complete list. This is why the done connector matters: it signals that the loop is finished and the Merge node can release its accumulated data.
In practice, this approach works reliably for small-to-medium batch jobs (a few hundred items). For very large jobs (thousands of items), persistent storage is more reliable because memory limits are real and crash-recovery from in-memory state is painful.
The SplitInBatches mental checklist: what to verify before you consider the batch loop done
Every time I wire up a SplitInBatches workflow, I run through the same questions:
- What's the batchSize and why? (It should be based on a real rate limit or memory constraint, not just "10 feels right.")
- Is there a Wait node between batches? (Almost always yes.)
- Am I writing per-batch results to persistent storage inside the loop?
- What happens to the done branch? (Does it do something useful, or is it just hanging there with no connections?)
- What happens if an item in the loop fails? (Error handling on the HTTP Request node โ set it before the first real run.)
- Did I reset any static data from a previous run? (If using
$workflow.staticData.) - Does ordering matter, and did I add a sequence index?
Check all seven and the workflow is usually fine. Skip one and you'll find out which one the hard way.
SplitInBatches is not a complicated node. The concept is simple: cut the list, loop through the chunks, continue when done. But the failure modes come from not thinking through how the loop interacts with state, storage, and error handling. The node is reliable. The workflows that break are the ones where the developer assumed the simple path and skipped the aggregation and error handling design.
For workflows I run against real APIs in production, the full pattern (batchSize + Wait + per-batch storage write + error branch + sequence index) takes about 15 minutes to set up and saves significant debugging time later. If you're only running it once on a clean dataset, skip the ceremony. If you're scheduling it to run weekly on a live contact list, don't.
Rate limits exist for a reason. They're not an obstacle to route around. They're information about how fast the API actually wants to talk to you. SplitInBatches is how you listen. Build the loop right once and it handles any size list without you thinking about it again.
Import a real batch-processing workflow
The n8n Starter Pack has 14 workflow files from a live business stack. The contact enrichment workflow uses SplitInBatches with rate-limit control, per-batch writes, and error handling all connected. Skip the setup and start from something that already works.
See Google Workspace MCP โOne-time $97 ยท Instant download ยท 30-day money-back guarantee