<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: stale_orbit</title>
    <description>The latest articles on DEV Community by stale_orbit (@stale_orbit).</description>
    <link>https://dev.to/stale_orbit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4039118%2F6b92b9c0-8f08-43de-818d-c827926fb8b1.jpg</url>
      <title>DEV Community: stale_orbit</title>
      <link>https://dev.to/stale_orbit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stale_orbit"/>
    <language>en</language>
    <item>
      <title>Are NocoBase 2.x workflows really "sequential"? I measured it, and everything ran in parallel</title>
      <dc:creator>stale_orbit</dc:creator>
      <pubDate>Tue, 21 Jul 2026 09:33:23 +0000</pubDate>
      <link>https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m</link>
      <guid>https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m</guid>
      <description>&lt;p&gt;If you read the NocoBase workflow docs the way I did, you come away thinking workflows can't run concurrently. Two sentences do most of the damage:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Workflows can be triggered concurrently, but they are executed &lt;strong&gt;sequentially in a queue&lt;/strong&gt;. Even if multiple workflows are triggered at the same time, they will be executed &lt;strong&gt;one by one, not in parallel&lt;/strong&gt;.&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/advanced/executions" rel="noopener noreferrer"&gt;Executions&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Regardless of the mode, each branch will be executed &lt;strong&gt;in order from left to right&lt;/strong&gt;.&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/nodes/parallel" rel="noopener noreferrer"&gt;Parallel branch&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Taken at face value, that reads as "workflows have no throughput parallelism, and even parallel branches run one after another." If it were true, it would be a serious constraint on how you design anything with real load.&lt;/p&gt;

&lt;p&gt;So I built the three scenarios on my own instance and measured them. My prediction was wrong three times in a row. The short version: &lt;strong&gt;everything runs in parallel, cleanly.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Test setup:&lt;/strong&gt; NocoBase 2.1.23 (official Docker image) + PostgreSQL 16, on a 12-core machine. Workflows were created, triggered, and measured entirely through the REST API. Elapsed time is the difference between &lt;code&gt;createdAt&lt;/code&gt; and &lt;code&gt;updatedAt&lt;/code&gt; on rows in the &lt;code&gt;executions&lt;/code&gt; table — not a stopwatch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Test 1: two "wait 5 seconds" branches under a parallel node
&lt;/h2&gt;

&lt;p&gt;I put a Parallel branch node in front of two branches, each with a single 5-second delay node.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sequential → &lt;strong&gt;10 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Parallel → &lt;strong&gt;5 s&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status = 1 (success) / elapsed 5.17 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5 seconds.&lt;/strong&gt; The two waits overlapped in real time. Looking at the job records, the two delay nodes started 11 ms apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parallel branch   created = 06:25:01.894
delay A (5s)      created = 06:25:01.904
delay B (5s)      created = 06:25:01.915   &amp;lt;- did not wait for branch A to finish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "executed in order from left to right" is about the order branches are &lt;em&gt;entered&lt;/em&gt;, not the order they &lt;em&gt;finish&lt;/em&gt;. The moment branch A parks itself on a wait, the engine hands control back and branch B starts. The waits stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: surely CPU work runs sequentially?
&lt;/h2&gt;

&lt;p&gt;Fine, waits overlap. But Node.js is single-threaded, so &lt;strong&gt;CPU-bound&lt;/strong&gt; work shouldn't overlap — that was my next assumption. I put a JavaScript node running a 3-second busy loop on each of two branches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;// hog the CPU for 3 seconds&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Sequential → &lt;strong&gt;6 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Parallel → &lt;strong&gt;3 s&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status = 1 (success) / elapsed 3.18 s
both branches returned result = 'done'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3 seconds again.&lt;/strong&gt; Even CPU work overlapped.&lt;/p&gt;

&lt;p&gt;The trick is in the plugin. The JavaScript node doesn't run on the main event loop — it runs in a &lt;strong&gt;&lt;code&gt;worker_threads&lt;/code&gt; worker&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// from @nocobase/plugin-workflow-javascript&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;import_node_worker_threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:worker_threads&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it runs on a separate thread, a CPU-bound script doesn't block the main loop. "It's Node, so it's single-threaded" was exactly the wrong intuition here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 3: fire four executions at the same time
&lt;/h2&gt;

&lt;p&gt;This is the one the docs speak to most directly: "even if multiple workflows are triggered at the same time, they will be executed one by one."&lt;/p&gt;

&lt;p&gt;I triggered &lt;strong&gt;the same 3-second-CPU workflow four times simultaneously.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sequential → &lt;strong&gt;12 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Parallel → &lt;strong&gt;3 s&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id   | status |   started    |   finished   | elapsed
------+--------+--------------+--------------+---------
 ...2 |      1 | 15:38:53.341 | 15:38:56.834 |  3.493
 ...3 |      1 | 15:38:53.395 | 15:38:56.782 |  3.387
 ...4 |      1 | 15:38:53.404 | 15:38:56.811 |  3.407
 ...5 |      1 | 15:38:53.408 | 15:38:56.823 |  3.415

wall-clock for all four: 3.49 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All four ran fully in parallel.&lt;/strong&gt; They started within ~70 ms of each other and finished within ~50 ms of each other. (Three 5-second delay executions fired together came in at 5.1 s wall-clock, same story.)&lt;/p&gt;

&lt;h2&gt;
  
  
  So are the docs wrong?
&lt;/h2&gt;

&lt;p&gt;No — and this is the part worth slowing down on. The same Executions page also says, in effect: a "Running" execution that is parked on a waiting node is &lt;em&gt;not&lt;/em&gt; holding the dispatch slot. Other queued executions can start while it waits.&lt;/p&gt;

&lt;p&gt;In other words, "sequential" describes &lt;strong&gt;the order the dispatcher pulls from the queue&lt;/strong&gt;, not "only one runs at a time." As soon as an execution hits a node that yields — a delay, a manual approval, an HTTP request — the next one starts. And JavaScript nodes escape to a worker thread entirely.&lt;/p&gt;

&lt;p&gt;But I'll say this plainly: the English phrasing &lt;strong&gt;"one by one, not in parallel"&lt;/strong&gt; reads as a throughput claim, and it made me design around a limit that isn't there. I misread it completely. Worth knowing: the Chinese-forum reports about "the queue getting stuck" (e.g. &lt;a href="https://forum.nocobase.com/t/topic/10331" rel="noopener noreferrer"&gt;t/10331&lt;/a&gt;) are &lt;strong&gt;all from the 1.x era&lt;/strong&gt;. In 2.x the dispatcher was reworked — &lt;a href="https://github.com/nocobase/nocobase/pull/9673" rel="noopener noreferrer"&gt;PR #9673&lt;/a&gt; fixed duplicate dispatch, &lt;a href="https://github.com/nocobase/nocobase/pull/9953" rel="noopener noreferrer"&gt;PR #9953&lt;/a&gt; eased lock contention — and it just runs concurrently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bottom line: you don't need to worry about 2.x workflow concurrency.&lt;/strong&gt; Waits overlap, CPU work overlaps, simultaneous triggers overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real traps are somewhere else
&lt;/h2&gt;

&lt;p&gt;Concurrency turned out to be a non-issue. But there are constraints that &lt;em&gt;do&lt;/em&gt; bite in production — and the docs state them outright. These matter far more than the parallelism question.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Bulk operations don't fire events
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"Collection events do not currently support triggering by &lt;strong&gt;bulk data operations&lt;/strong&gt;."&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/triggers/collection" rel="noopener noreferrer"&gt;Collection event&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"using the &lt;strong&gt;SQL action node&lt;/strong&gt; to operate on the database is equivalent to direct database operations and &lt;strong&gt;will not trigger collection events&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A workflow you thought you wired up silently does nothing&lt;/strong&gt; depending on the path that changed the data. CSV bulk import or an update via the SQL node won't trigger it. If you put business logic in workflows, this is the biggest footgun.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Missed schedules never recover
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"if ... the entire NocoBase application service is in an inactive or shutdown state, the scheduled task ... will be &lt;strong&gt;missed&lt;/strong&gt;. Moreover, after the service is restarted, the &lt;strong&gt;missed tasks will not be triggered again&lt;/strong&gt;."&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/triggers/schedule" rel="noopener noreferrer"&gt;Schedule event&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Any scheduled run that lands during a deploy or upgrade restart is silently dropped&lt;/strong&gt; — and never replayed. If a daily batch lives in a workflow, mind your restart windows.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Loop limit defaults to unlimited
&lt;/h3&gt;

&lt;p&gt;You can cap it with the &lt;code&gt;WORKFLOW_LOOP_LIMIT&lt;/code&gt; env var, but &lt;strong&gt;the default is unlimited&lt;/strong&gt; (&lt;a href="https://docs.nocobase.com/workflow/nodes/loop" rel="noopener noreferrer"&gt;Loop node&lt;/a&gt;). Get a loop condition wrong and nothing stops it. Set this in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. No failure notifications
&lt;/h3&gt;

&lt;p&gt;To a forum question — "my scheduled workflow errors out sometimes, there's no alert, and I only find out by digging through the history" — the official reply was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"你好，目前没有" (currently, there is none)&lt;br&gt;
— &lt;a href="https://forum.nocobase.com/t/topic/12040" rel="noopener noreferrer"&gt;forum t/12040&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Put something critical in a workflow and it can fail quietly.&lt;/strong&gt; You have to build the monitoring yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The transaction node arrived in 2.2.0
&lt;/h3&gt;

&lt;p&gt;The DB transaction node — the one that rolls back a group of operations together — was &lt;strong&gt;added in 2.2.0&lt;/strong&gt; (&lt;a href="https://docs.nocobase.com/workflow/nodes/transaction" rel="noopener noreferrer"&gt;Transaction node&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Which means &lt;strong&gt;2.0 / 2.1 workflows have no all-or-nothing guarantee.&lt;/strong&gt; Take a workflow that "① decrements stock → ② writes a shipment record." If ② fails, &lt;strong&gt;① stays applied.&lt;/strong&gt; Stock went down, no record exists — data left in a half-broken state. If you're running anything where integrity is the whole point (money, inventory) on 2.1 or earlier, keep this in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency was never the problem&lt;/strong&gt; (measured on 2.1.23). Parallel branches, and multiple simultaneously-triggered executions, overlap cleanly — for both waits and CPU work. JavaScript nodes run on &lt;code&gt;worker_threads&lt;/code&gt;, so the single-thread intuition doesn't apply.&lt;/li&gt;
&lt;li&gt;The official "sequentially in a queue / one by one, not in parallel" is about &lt;strong&gt;dispatch order&lt;/strong&gt;. Read as a throughput claim, it misleads. (It misled me.)&lt;/li&gt;
&lt;li&gt;The Chinese-forum "queue gets stuck" reports are &lt;strong&gt;all 1.x&lt;/strong&gt;. The 2.x dispatcher was rebuilt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real traps are "won't fire / won't notice / breaks halfway"&lt;/strong&gt; — bulk ops and the SQL node skip events, schedules are dropped on restart, failures are silent, and pre-2.2 has no transaction node so a mid-workflow failure leaves data inconsistent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading one sentence of docs and designing around it cost me more than spinning up Docker and measuring would have. The environment is one &lt;code&gt;docker compose up&lt;/code&gt; away — if something about your workflow design worries you, measure it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Measured on 2.1.23 / PostgreSQL 16 / 12 cores. Worker-thread count tracks CPU cores, so on a small box the CPU-parallelism will be lower.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/advanced/executions" rel="noopener noreferrer"&gt;Executions (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/nodes/parallel" rel="noopener noreferrer"&gt;Parallel branch node (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/triggers/collection" rel="noopener noreferrer"&gt;Collection event trigger (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/triggers/schedule" rel="noopener noreferrer"&gt;Schedule event trigger (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/nodes/transaction" rel="noopener noreferrer"&gt;Transaction node (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/nocobase/nocobase/pull/9673" rel="noopener noreferrer"&gt;PR #9673: fix duplicate dispatch&lt;/a&gt; / &lt;a href="https://github.com/nocobase/nocobase/pull/9953" rel="noopener noreferrer"&gt;PR #9953: ease lock contention&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/12040" rel="noopener noreferrer"&gt;Forum: no failure notifications yet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nocobase</category>
      <category>lowcode</category>
      <category>workflow</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
