Module 5: Dependencies Between Workflows

5. Ordering and backpressure with queue mode

Description

By the end of this lesson you will be able to recognize and reason about what happens when events arrive faster than your system processes them: buildup — a growing queue, lost ordering — events applied backwards, and, in the worst case, loss of events. You will understand what n8n's queue mode is and how it gives you two different things — capacity (more workers) and pace control (concurrency limits) — and you will learn the uncomfortable consequence almost no one anticipates: that running things truly in parallel does not preserve order on its own, and that order, when it matters, has to be guaranteed at the data layer with a version and the ledger, not entrusted to the engine.

This matters because backpressure — that odd name for "more is coming in than I can push out" — is the cause of a whole family of bugs that only show up under load and therefore never appear in the demo. An order and its correction applied backwards, leaving inventory wrong; a burst of events that saturates the instance and some go unprocessed; a queue that grows without anyone watching it until latency spikes. These are pacing problems, not logic problems, and they're diagnosed differently. This lesson gives you the vocabulary and the defenses.

Connection to the module: lesson 4 left a door open — fan-out gives you parallelism, and real parallelism comes from queue mode — and this lesson walks through it. It's also where disaster 2 from lesson 1, lost ordering, gets resolved. And it sets up lesson 6: part of the defense against backpressure is decoupling "receiving" from "processing" with an intermediate queue, which is halfway to the outbox pattern. A scope note, the same one announced in the introduction: here we treat queue mode as the mechanism that gives capacity and pace control; standing up and sizing a worker cluster in production is the production and maintenance guide's job. You'll understand what it is and why it matters for ordering; tuning it for scale is a different guide.

When more comes in than goes out

Think of a coffee shop counter at rush hour. There's a single barista. Customers order faster than the barista makes coffees. What happens? A line forms. As long as the line moves at a reasonable pace, everything's fine. But if orders keep coming in faster than coffees go out, the line grows, and grows, and three bad things start to happen.

The first: the wait spikes. The last customer who arrived has to wait through every coffee ahead of them. The line isn't just long, it's slow to get through.

The second, more subtle: order can get lost. Suppose to go faster, the shop puts on a second barista. Now there are two working from the same line. Customer A ordered before B, but barista 1 took A's order (an elaborate, slow coffee) and barista 2 took B's (a simple, quick one). B gets their coffee before A, even though they ordered after. If order mattered — imagine A ordered "a coffee" and B, a second later, ordered "cancel the previous one, tea instead" — processing them in parallel and out of turn produces a wrong result: the coffee that was going to be cancelled gets made.

The third, the worst: loss. If the line grows without limit and the shop closes, the customers left unserved leave without their coffee. In a system, if the event queue grows beyond what memory or configuration can hold, or if events have a lifespan and expire, some simply never get processed.

Those three things — spiking wait, lost order, lost events — are what happens when the producer goes faster than the consumer. The technical name for that situation is backpressure: the pressure that builds up backward when output can't keep up with input. The whole lesson is about how to handle it without letting it cause damage.

Backpressure at Cumbre

Let's bring this to Cumbre's orders. The vendor — the online store — fires a webhook for every order. On a normal day, few orders arrive per minute and order-triage processes them with room to spare. But then Buen Fin arrives, or a promotion, and suddenly hundreds of orders per minute come in. order-triage, which also calls check-credit (which waits on the bureau) and discounts inventory, doesn't process as fast as they arrive. The line forms.

And here Cumbre suffers all three forms of the problem:

  • Buildup: order-triage executions get queued up. An order that came in at 12:00 might not finish processing until 12:08. The customer already got the "order received" email from the store, but in Cumbre's system it doesn't exist yet.
  • Lost ordering: a customer places an order and modifies it thirty seconds later. Two events arrive for the same order_id: "order of 12 kg" and "correction: 8 kg." If those two events get processed in parallel, or if the second gets ahead of the first, inventory-sync can end up discounting 12 kg when it should have discounted 8, or applying the correction and then the original order on top of it. Disaster 2 from lesson 1, live.
  • Loss: if the burst is big enough that the instance saturates and some executions fail from lack of resources without getting retried, there are orders that never got processed and no one found out.

The three have different defenses, and queue mode is the central tool for the first two. Let's look at it.

What queue mode is

By default, an n8n instance runs in regular mode: a single main process receives the triggers and executes the workflows itself. It's simple and covers a lot of ground. But that single process is a limit: if more executions come in than it can run at once, the line forms, and there's no one else to ask for help.

Queue mode changes that architecture. Instead of one process doing everything, there are three pieces:

  • A main process that receives the triggers — the webhooks, the schedules — but does not execute the workflows. Instead of executing them, it queues them.
  • A queue, which in n8n is Redis (an in-memory database, very fast, built exactly for queueing). The main process puts every pending execution there.
  • One or several workers: separate processes whose only job is to pull executions off the Redis queue, run them, and save the result in Postgres.

The analogy is direct: the main process is the one that takes orders at the counter and writes them on a rail; Redis is the order rail; the workers are the baristas who take orders off the rail and prepare them. The advantage is obvious: if the line grows, you add more baristas — more workers — and capacity goes up. You're no longer dependent on a single process.

# Regular mode
   triggers ──▶ [ main process: receives AND executes ] ──▶ Postgres

# Queue mode
   triggers ──▶ [ main: receives and queues ] ──▶ Redis ──┬──▶ worker 1 ──▶ Postgres
                                                            ├──▶ worker 2 ──▶ Postgres
                                                            └──▶ worker 3 ──▶ Postgres

Turning on queue mode is, broadly speaking, setting the EXECUTIONS_MODE=queue variable on the main process and the workers, and having a Redis instance available. It's included in the self-hosted Community edition; it isn't a paid feature. The setup details — how many workers, how they connect, how to size Redis — belong to the production guide; here we stay at what it does and why it matters.

The two things queue mode gives you: capacity and pace control

Queue mode solves two different problems, and it's worth not confusing them.

Capacity: more workers, more throughput. If the backpressure comes from not processing fast enough, adding workers processes more in parallel. Three workers do roughly triple the work per minute of one. This attacks buildup: the line drains faster because there are more hands.

Pace control: concurrency limits. Sometimes the problem is the opposite: you don't want to process too fast, because something fragile is downstream. If inventory-sync hits a warehouse system that can only handle twenty simultaneous requests, unleashing a hundred in parallel knocks it over. Here you want a concurrency limit: "process at most N at a time." n8n has a concurrency control — the N8N_CONCURRENCY_PRODUCTION_LIMIT variable sets how many production executions run at once, and in queue mode each worker also has its own limit (the --concurrency option when starting the worker). This turns your system into a regulator: whatever happens at the entrance, no more than N reach downstream at a time. It's a defense against lesson 1's cascade: you put a ceiling on how much you can saturate a fragile service.

Notice the tension between the two. Capacity wants to go faster; pace control wants to not go too fast. The right point depends on your bottleneck: if the bottleneck is n8n, you raise capacity; if the bottleneck is a downstream service, you slow the pace so as not to knock it over. Diagnosing which of the two is your bottleneck — by looking at where the queue piles up — is half the job of operating under load.

The surprise: parallelism does not preserve order

Here's the part almost no one anticipates, and the reason this lesson puts "order" and "backpressure" in the same title.

When you turn on queue mode and add several workers, you gain capacity — but you lose the order guarantee. Redis distributes queue executions among the workers in arrival order, but the workers run in parallel and at different paces. Worker 1 takes event A, worker 2 takes event B that arrived right after, and if A's job takes longer than B's, B finishes before A. It's exactly the two-baristas case: whoever ordered later receives first.

For most events, this makes no difference: two orders from two different customers can be processed in any order without a problem. The danger shows up when two events belong to the same order_id and have a correct order relative to each other. The order and its correction. The creation and the cancellation. If those two land on different workers and get processed out of turn, the result ends up wrong, and there's no error: the system did its job, just in the wrong order.

The conclusion is harsh and worth memorizing: the engine gives you capacity, not order. If your system has events that must be applied in a certain order, you can't entrust that order to the execution engine — regular or queue mode. You have to guarantee it yourself, at the data layer. And the good news is you already have the tool to do it: the ledger.

Guaranteeing order at the data layer

There are two ways to make sure that events for the same order_id don't get applied wrong, and both live in the data, not the engine.

Form 1 — Version + check (optimistic concurrency control)

The idea: every event for an order carries a version number that says its place in the sequence. The original order is version 1; the correction is version 2. And in the ledger you store, for each order_id, the last version you applied. Before applying an event, you compare:

  • If the incoming event's version is greater than the last one applied, it's newer: you apply it and update the stored version.
  • If it's less than or equal, it's old or repeated: you don't apply it, because you already processed something newer.
Event arrives with order_id = ORD-2041, version = 2 (the correction: 8 kg)

1. Postgres: SELECT last_version FROM ledger WHERE order_id = 'ORD-2041';
2. IF event_version > last_version:
       apply the effect (discount 8 kg)
       UPDATE ledger SET last_version = 2 WHERE order_id = 'ORD-2041';
   ELSE:
       ignore (an older or repeated event arrived)

With this, order stops mattering in the engine. If the correction (v2) arrives and gets processed before the original (v1) — because it landed on a faster worker — when the original (v1) arrives later, the check sees last_version is already 2, and discards v1 as stale. The final result is correct — version 2 sticks — no matter what order they were processed in. This technique is called optimistic concurrency control: you don't lock anything, you let things run in parallel, and you use the version so that disorder doesn't cause damage. It's the same family of ideas as the conditional write you saw in Module 2.

Form 2 — Serialize by key

The other form is more direct and more costly: process events for the same order_id one at a time, never in parallel. Different orders keep running in parallel — that's what gives you capacity — but two events for the same order get serialized.

In n8n, guaranteeing this strictly requires a locking mechanism: before processing an event for ORD-2041, you take a "lock" for that order_id in the ledger (a row that marks "ORD-2041 in progress"); if another worker already has it, you wait or requeue. It's more complex to build and reduces parallelism for events that share a key. It's justified when order is critical and you can't represent the sequence with a version — for instance, when each event is an increment that depends on the previous state, rather than a replacement.

Which one to use. For most of Cumbre's cases — where an event replaces the state (the 8 kg correction replaces the 12 kg) — Form 1, version + check, is simpler and doesn't sacrifice parallelism. Form 2, serialize, is reserved for when events are incremental and truly cannot be applied out of order. Always start with the version; move up to serializing only if the version can't capture your sequence.

Worked example: the order and its correction, resolved with a version

Let's see Form 1 in full with the scary case: the 12 kg order and its correction to 8 kg, processed backwards.

The setup. Every order event Cumbre receives now carries a version field that the vendor increments with every change to the same order:

{ "order_id": "ORD-2041", "version": 1, "quantity_kg": 12 }   // the original order
{ "order_id": "ORD-2041", "version": 2, "quantity_kg": 8 }    // the correction

In the ledger, a last_version column per order_id. In inventory-sync, before discounting, the check from above.

The out-of-order scenario. It's Buen Fin, there are three workers, and the two events land on different workers. By bad luck, the correction (v2) gets processed first:

What to expect, step by step.

  1. v2 (8 kg) arrives at a worker. It checks the ledger: last_version for ORD-2041 is empty (or 0). Since 2 > 0, it applies: discounts 8 kg and sets last_version = 2.
  2. A moment later, v1 (12 kg) arrives at another worker. It checks the ledger: last_version is already 2. Since 1 > 2 is false, it applies nothing. The v1 event, being older, gets discarded.
  3. Final state: 8 kg discounted, last_version = 2. Correct, even though the events were processed in the wrong order.

Compare that to what would have happened without the version: v2 discounts 8 kg, then v1 discounts 12 kg on top, and you end up with 20 kg discounted or with the order in the old state — either way, wrong. The version turned a dangerous out-of-order situation into a harmless one. That's the goal: not preventing things from arriving out of order — that's very expensive and sometimes impossible — but making sure disorder does no damage.

An honest detail. This assumes the vendor gives you a reliable, increasing version. If it doesn't, you can sometimes use an event timestamp (created_at) as a substitute — "apply only if this event is newer than the last one applied" — with the caveat that two events with the same timestamp, or unsynchronized clocks, complicate the comparison. If you have neither a version nor a reliable timestamp, you're in Form 2 territory (serialize) or need to negotiate with the vendor to include one. It's a good example of why Module 3's input contract matters: a version field in the contract is what makes this whole defense possible.

Defenses against buildup and loss

The version solves ordering. For the other two forms of backpressure — buildup and loss — the defenses are about design:

  • Against buildup: more capacity (more workers in queue mode) if the bottleneck is n8n; or pace control (a concurrency limit) if the bottleneck is a downstream service and you'd rather have an orderly line than a cascade. The Redis queue, moreover, is itself a defense: it absorbs bursts. A burst that in regular mode would knock over the process, in queue mode just sits waiting in Redis until the workers drain it. The queue is a buffer.
  • Against loss: decouple "receiving" from "processing." If order-triage receives the webhook and the only thing it does right away is record the event in a table (or queue it) and respond to the vendor, then the event is safe even if processing lags or fails: it's already stored, and can be processed later. What gets lost is what gets received and immediately attempted to be processed in one go without storing it first. Receive-and-store-fast, process-later, is exactly the shape of lesson 6's outbox pattern. Backpressure is one of the reasons that pattern exists.

Notice how the three lessons tie together: the ledger (M4) resolves order with versions; the Redis queue absorbs bursts; and the receive/process decoupling — which lesson 6 formalizes as the outbox — prevents loss. No defense is new; they're the module's pieces applied to the pacing problem.

Common mistakes

Assuming that if events fire in order, they get processed in order (conceptual). What happens: someone reasons "the vendor sends the order before the correction, so they get processed in that order" and doesn't protect against disorder. Under load, with several workers, the correction gets ahead of the order and inventory ends up wrong, with no visible error. Why it happens: in regular mode and with low load, firing order almost always matches processing order, so the assumption looks true for a long time. How to detect it: ask yourself "what happens if event 2 for this order_id gets processed before event 1?" If the answer is "it ends up wrong," your order isn't protected. How to fix it: don't entrust order to the engine; put it in the data with a version and the "apply only if newer" check. Firing order does not guarantee application order once there's parallelism.

Adding workers to go faster and breaking order without noticing (practical). What happens: under load, someone turns on queue mode and bumps up to four workers to drain the line, latency improves, everyone's happy — and a week later inconsistent inventory shows up that wasn't there before. Why it happens: the parallelism that gained capacity is the same thing that broke the order of events sharing a key, and the cause-and-effect isn't obvious because the symptom shows up later. How to detect it: if ordering problems started right when you bumped concurrency or workers, that's the clue. How to fix it: before raising parallelism, protect order at the data layer (version + check) for events that share a key; capacity and order get solved at different layers, and raising one without minding the other trades one problem for another.

Confusing "more capacity" with "less pace" and pulling the wrong lever (practical). What happens: the system falls behind because inventory-sync is knocking over the warehouse system with too many simultaneous requests, and someone, to "go faster," adds workers — which sends even more simultaneous requests to the already-saturated warehouse. Why it happens: "it's slow" gets reflexively translated to "I need more capacity," without diagnosing where the bottleneck is. How to detect it: look at where the queue piles up and what's failing; if the one suffering is a downstream service (the warehouse returns saturation errors), your problem isn't a lack of your own capacity, it's excess pace toward it. How to fix it: when the bottleneck is a fragile downstream service, the lever is lowering concurrency (a limit), not adding workers; more workers only helps when the bottleneck is you.

Processing the webhook straight through without storing it first, and losing events in a burst (conceptual). What happens: order-triage receives the webhook and processes it whole — credit, inventory, CRM — before responding to the vendor; in a burst, some executions fail from saturation and those orders get lost, because they were never stored anywhere. Why it happens: it's the most natural thing — receive and process in one flow — and at low volume it never fails. How to detect it: ask yourself "if processing this webhook fails, is there a record the event arrived?" If the answer is no, a failure under load is a lost event. How to fix it: separate receiving from processing — on receipt, record the event in a table and respond fast; process later from that table — so a processing failure is recoverable because the event stayed stored. It's the doorway into lesson 6's outbox pattern.

Exercises

Exercise 1 — Identify the form of backpressure. For each symptom, say which of the three forms it is — buildup, lost ordering, or lost events — and an appropriate defense:

(a) On Buen Fin, an order that came in at 12:00 doesn't show up in the CRM until 12:09, but it ends up correct. (b) A customer corrected their order and inventory ended up discounted according to the old version, even though both events arrived. (c) After a huge burst, the team notices three orders are missing that the vendor confirms sending and that left no trace at all in the system.

See solution

(a) Buildup. The order processed fine, just late: the line grew and drained slowly. The defense is capacity — more workers in queue mode — if the bottleneck is n8n, or accepting the latency if the load is a one-off spike and the Redis queue absorbs it without losing anything.

(b) Lost ordering. Both events arrived but got applied wrong: the old version stuck. The defense is the data layer: a version per event and the "apply only if newer" check, which discards the old event even if it arrives later.

(c) Lost events. Three orders that arrived and left no trace: they were attempted straight through, failed under load, and since they were never stored, disappeared. The defense is decoupling receiving from processing — recording the event on receipt, before processing it — so a failure is recoverable. It's lesson 6's outbox.

Why this works: the three forms have different defenses — capacity, version, decoupling — and confusing them leads to pulling the wrong lever. Naming the form is what tells you which defense applies.

Exercise 2 — Trace the disorder with and without a version. Two events for order ORD-3300 arrive under load and land on different workers: v1 (create order, 20 units) and v2 (cancel order). Because of parallelism, v2 (cancel) gets processed before v1 (create). Trace what happens without a version and with version + check, and say what the correct final state is.

See solution

The correct final state is: the order cancelled (v2 is the newest, it wins).

Without a version:

  1. v2 (cancel) gets processed first. But the order doesn't exist yet — v1 hasn't run — so cancelling might fail, or mark something as cancelled that isn't there, or do nothing.
  2. Then v1 (create) gets processed: it creates the order with 20 units, active.
  3. Final state: active order with 20 units. Wrong — it should have ended up cancelled. The customer who cancelled gets their order anyway.

With version + check:

  1. v2 (cancel) gets processed. The ledger checks last_version for ORD-3300: empty. Since 2 > 0, it applies the cancellation and sets last_version = 2.
  2. v1 (create) gets processed. The ledger checks last_version: already 2. Since 1 > 2 is false, it discards v1 as old. Creates nothing.
  3. Final state: order cancelled, last_version = 2. Correct, despite the disorder.

(A nuance: for "cancel before create" to be well represented, the ledger stores the newest state by version, it doesn't blindly execute; the version check is what guarantees the final state reflects the newest event, regardless of arrival order.)

Why this works: you showed that without a version the disorder produces a wrong state and with a version it produces the correct one, which is the whole thesis of the lesson: order gets guaranteed in the data, not the engine. The "cancel before create" case is sharper than the quantities one because the damage — delivering a cancelled order — is more visible.

Exercise 3 — Diagnose the bottleneck. Cumbre's system falls badly behind under load. You have two observations: (1) the Redis queue grows and doesn't drain; (2) the warehouse system inventory-sync uses returns lots of "too many requests" errors. With those two clues, what's the bottleneck, and why would adding more workers make things worse? What's the correct lever?

See solution

The bottleneck is the downstream warehouse system, not n8n's capacity. The clue is in observation (2): the warehouse is returning saturation errors, which means it's already getting more than it can handle. The Redis queue growing (observation 1) isn't because n8n lacks hands, but because the work gets stuck waiting on a warehouse that rejects requests and forces retries, which in turn adds more pressure.

Why adding workers would make it worse: more workers means more parallel inventory-sync executions, and therefore more simultaneous requests to the warehouse, which is already saturated. It's piling more pressure onto the one that can't already keep up: the warehouse would return even more errors, there would be even more retries, and the queue would grow faster. The reflex of "it's slow, I'll add capacity" is exactly the trap here.

The correct lever is lowering the pace toward the warehouse: a concurrency limit that guarantees inventory-sync — and therefore the warehouse — never gets more than N requests at a time, within what the warehouse can handle. That turns the burst into an orderly line the warehouse can process without going down. You trade instant speed for the work actually moving forward instead of bouncing back. It's lesson 1's cascade defense, applied with queue mode's pace-control lever.

Why this works: you diagnosed the bottleneck by looking at where things fail (the warehouse, not n8n) instead of assuming "slow = needs capacity," and you chose the lever opposite to intuition — lowering pace, not raising capacity — because the bottleneck is downstream. That diagnosis is the core skill of operating under backpressure.

Summary and next step

In this lesson you faced backpressure: what happens when more comes in than goes out. It shows up in three forms — buildup (the line grows), lost ordering (events applied backwards), and loss (events disappear) — and each has its own defense. n8n's queue mode — main process that queues, Redis as the queue, workers that process — gives you two different things: capacity (more workers, against buildup) and pace control (concurrency limits like N8N_CONCURRENCY_PRODUCTION_LIMIT and --concurrency, against a cascade toward a fragile service). And you saw the key surprise: running in parallel does not preserve order, because workers process at different paces; the engine gives you capacity, not order. Order, when it matters, gets guaranteed at the data layer — with a version per event and the "apply only if newer" check (optimistic concurrency control), or by serializing by key in incremental cases. And against loss, decoupling receiving from processing, which is the doorway to the outbox.

Before moving on to lesson 6 you should be able to: define backpressure and its three forms; explain what queue mode's capacity and pace control are, and when to pull each lever; and explain why parallelism breaks order and how a version field in the ledger fixes it without sacrificing parallelism.

Lesson 6 is the module's hinge. You're going to see the outbox pattern, the technique that separates "deciding the effect" from "executing the effect": the workflow records the effect's intent in an outbox table — in the same operation where it registers its decision — and another flow reads it and executes it idempotently. It's what makes a failure between steps not duplicate or lose an effect down the chain, it's the robust form of decoupling receiving from processing that peeked through in this lesson, and it's the missing piece for coordinating several workflows with real safety. Everything you've seen so far — idempotency, ledger, graph, fan-out, ordering — converges there.

Resources

  • Scaling n8n / Queue mode — n8n Docs — what queue mode is, its pieces (main, Redis, workers), and how it's turned on with EXECUTIONS_MODE=queue. The full setup belongs to the production guide; the concept is enough here.
  • Concurrency control — n8n Docs — pace control: N8N_CONCURRENCY_PRODUCTION_LIMIT for the concurrent-executions limit and the per-worker --concurrency option in queue mode. Check the exact names in your version.
  • Queue mode environment variables — n8n Docs — the variables that configure the queue and the workers, for when you move to a real setup (production guide).
  • Postgres node — n8n Docs — the node you use to implement the version check against the ledger: reading last_version and updating it conditionally.
  • Webhook node — n8n Docs — the entry point where backpressure is born and where it pays off to receive-and-store-fast instead of processing straight through.