# From Visual Workflows to Native Code in Production: The Complete Journey of an n8n Backend That Couldn't Stop Evolving

> Source: <https://dev.to/rogeriomaciel/from-visual-workflows-to-native-code-in-production-the-complete-journey-of-an-n8n-backend-that-508j>
> Published: 2026-08-09 14:54:01+00:00

I'll tell this story in the order it happened. It doesn't start with a compiler. It starts with two n8n servers and a synchronization problem I had to solve before I could sleep peacefully.

CoreAutoCRM is an auto repair shop management SaaS operating entirely via WhatsApp. AI customer service, scheduling, quotes, yard management, intelligent follow-ups—all of this lives in n8n workflows. I chose n8n for the obvious reason: speed. In a few months, I went from zero to a real production product, built entirely alone.

n8n is great for that. The problem starts when you have a real product, with a real client, and you need two environments: one to test without fear of breaking what works, and another for what the client actually uses.

Staging and production. Two separate n8n servers.

And then comes the problem nobody mentions: **how do you ensure that what you tested in staging is exactly what goes to production?**

In n8n, a workflow lives inside the server's database. It's not a file. It has no history. It has no diff. If you open the visual editor and change a `Switch`

condition, a SQL query parameter, or a node's AI prompt—that change exists only in that server's UI. There's no way to know what changed, who changed it, or when. There's no way to revert. There's no way to review before deploying to production.

The "manual" way to sync staging with production would be: open each workflow in staging, export the JSON, open the same workflow in production, import the JSON. With 74 workflows, this is unfeasible. And even if it were, it would be a manual process prone to human error—the kind of thing that wakes you up at 2 AM because someone (you) forgot to sync a critical workflow.

I needed a solution that:

This didn't exist out of the box. I built it from scratch.

n8n's REST API has endpoints for everything: listing workflows, fetching a specific workflow's JSON, creating, updating, activating, deactivating. What I needed was an automation that used this API in both directions.

I built a synchronization skill that does the following:

**Direction 1 — n8n → Git (download):**

The automation connects to the staging n8n server via API, downloads each workflow's JSON, organizes them into a folder structure in the local project (`/agente/workflows/`

), and prepares a merge request with the changes. I can do this at any time—after a development cycle in the visual editor, when I want to "commit" the backend's current state.

**Direction 2 — Git → n8n (upload):**

The reverse also works. I can edit a JSON directly in the repository—with AI assistance, since it's just structured text—and publish that change back to the n8n server. For quick tweaks, editing the JSON is sometimes faster than navigating the visual interface.

**The CI/CD pipeline:**

When a merge request is approved in Git, the CI/CD pipeline kicks in automatically. It uses the production n8n API to publish each changed workflow directly to the production server, with no manual intervention. What was tested in staging is exactly what hits production—because it's the exact same JSON, versioned, reviewed, and approved.

The result was a setup I didn't expect to be so good: **visual backend development with traditional software engineering discipline**. Each backend feature became a branch. Each change had a readable Git diff. Each deploy had full traceability. And all this without giving up the n8n visual editor, which remained the most productive tool for building and testing flows quickly.

With this structure running, something important happened quietly: I now had 74 workflows—the entire product backend at the time—as structured, updated, versioned, and synced JSON files in the repository. The backend source code existed in a machine-readable format.

I still didn't know what to do with this besides versioning. But I was about to find out.

The GitOps structure completely solved governance. I never lost trace of a change again. I never feared syncing staging with production again. The pipeline worked beautifully.

Meanwhile, the product kept growing. The original 74 workflows became 126—new features, new AI modules, new operational flows. All versioned, all synced, all going through the same pipeline. The GitOps structure scaled naturally with the product's growth.

But n8n was still the production runtime. And as the volume grew, the cost of that became more visible.

Every n8n node serializes and deserializes the entire state between executions. It's the inherent cost of any visual orchestration engine that needs to be generic enough to serve everyone. Every subworkflow call—and I had many, because my n8n microservices architecture used subworkflows heavily—turned into an internal HTTP call with authentication, serialization, and transport overhead. Result: 180ms to 450ms average latency, 1.2 GB to 2.5 GB of RAM per instance.

For a SaaS that handles real-time WhatsApp messages, this is a real problem. The feeling of "taking too long to respond" starts showing up in the product before you have enough volume to justify a traditional rewrite.

The conventional options:

**Rewrite in native code.** Six months of work, a complete halt on new features, a risky migration, and—most importantly—the end of the visual development speed that got me where I was. Discarded.

**Scale horizontally with more n8n instances.** Multiplies an already high cost without solving per-request latency. Discarded.

And then the realization that changed everything: **I already had the 74 workflows as structured JSON in the repository**. If there is a program capable of reading these JSONs and understanding what each workflow does—what each node receives, processes, and outputs—that program can generate equivalent TypeScript code. Code that has no serialization between nodes. That makes no internal HTTP calls. That runs straight in the process, with no orchestration engine in the middle.

The GitOps I built to solve versioning had inadvertently created the prerequisite for the next step: a compiler.

The first version of the compiler followed the nodes in the order they appear in the JSON. It broke immediately.

Visual workflows have no linear order. A JWT authentication node might appear at position 3 in the JSON, but needs to execute before the database node at position 1. An `If`

node triggers only one of two branches. A node receiving input from two different branches must wait for at least one of them to execute.

Visual workflows are Directed Acyclic Graphs—DAGs. The only way to correctly resolve the execution order is with topological sorting.

I used Kahn's Algorithm: start with nodes that have no predecessors (the triggers), process them, mark them as resolved, free the nodes that depended on them, repeat. The result is a linear queue ensuring every node only executes after all its predecessors have already executed.

The compiler also identifies SINK nodes—nodes that end execution and return the HTTP response (`Respond to Webhook`

). These nodes are placed at the end of the topological queue, allowing the response to the client to be returned in less than 5ms while secondary asynchronous tasks continue in the background.

My first implementation of subworkflows used standard ES Module imports. It worked in development. It broke silently in the production bundle.

The problem is twofold.

First: ES modules with relative paths of different depths can result in separate `Map`

instances in the same process. Two modules thinking they share the same registry might be talking to different registries with no visible errors.

Second: bundlers like `bun build`

and `esbuild`

perform tree-shaking—removing code unreferenced in static analysis. Subworkflows called dynamically by name (`executeSubworkflow("COREAUTOCRM-PANEL-ACTION-GET-OS-DETAILS", ...)`

) are invisible to the bundler. The name is a string at runtime. The bundler doesn't know that string corresponds to a function. The bundle reached production without the subworkflows, and the dynamic calls failed silently.

Solution for both problems at once: Global Singleton Registry tied to `globalThis`

.

``` js
const g = globalThis as any;
if (!g.__COREAUTO_WORKFLOWS_REGISTRY__) {
  g.__COREAUTO_WORKFLOWS_REGISTRY__ = new Map<string, Function>();
}
export const workflowsRegistry = g.__COREAUTO_WORKFLOWS_REGISTRY__;
```

`globalThis`

is guaranteed to be unique per process. To solve tree-shaking, the compiler automatically injects a static export referencing all 126 workflows, forcing the bundler to include them all in the bundle.

n8n uses its own expression syntax: `={{ $json.body.osId }}`

, `={{ $('NodeName').item.json.field }}`

, `Text: {{ $json.name }}`

. Converting this to TypeScript has three distinct problems.

First is safe chaining. `$json.user.store_id`

crashes with a `TypeError`

if `user`

is null. The compiler needs to convert to `item?.json?.user?.store_id`

for paths of arbitrary depth.

Second is mixing text and expressions. `"Text: {{ $json.name }}"`

must become ``Text: ${item?.json?.name ?? ''}``

—a template string with a fallback.

Third, I didn't expect: AI prompts. The prompts feeding Gemini in the workflows contain Markdown code blocks with triple backticks. When the compiler places these prompts inside TypeScript template strings, the internal backticks break the syntax. The parser had to learn to identify these tokens and escape each internal backtick with `\`

`.

The solution was a real tokenizer—not a regex over the entire text, but a parser that identifies the `{{`

and `}}`

delimiters, extracts the expression content, converts n8n's grammar to TypeScript, and reconstructs the text with proper escaping for each context.

Generating code that compiles is not enough. I need to guarantee that the generated code produces exactly the same result n8n would with the same data.

The n8n API exposes the full execution history: `GET /api/v1/executions`

. For each execution, it returns the input payload, intermediate data for each node, and final output payload.

The `TestGenerator`

uses this API as an oracle: I run a workflow in staging n8n with real data, the generator fetches this execution, captures input and output for each node, and generates a `.test.ts`

file that verifies the compiled TypeScript function produces an identical output, field by field.

If any field diverges, the test fails, and the CI blocks the deploy. Staging n8n isn't just the visual IDE—it's the correctness oracle for everything going to production.

| Metric | n8n on VPS | Compiled Fastify | Difference |
|---|---|---|---|
| Average Latency | 180ms – 450ms | 3ms – 8ms | ~35x faster |
| RAM per instance | 1.2 GB – 2.5 GB | 80 MB – 120 MB | ~95% less |
| Throughput per vCPU | ~120 req/s | > 4,500 req/s | ~37x more scale |
| Compile Time (126 workflows) | — | 77ms | Instantaneous |

The production bundle is 2.6 MB. It boots with PM2, sits behind Nginx, and is deployed automatically by CI when all tests pass.

Looking back, what happened was a sequence where each solved problem created the conditions for the next step:

The problem of syncing two n8n servers forced me to build GitOps automation. GitOps gave me 74 workflows as versioned JSONs in the repo. The JSONs in the repo created the prerequisite for the compiler. The compiler turned those JSONs into native code with automatic correctness guarantees.

None of these steps were planned from the start. Each solved a real problem and created, as a side effect, the infrastructure the next step needed.

Today the full flow is:

`bun run transpile`

compiles the JSONs into native TypeScriptn8n remains the development tool. Fastify is the production runtime. The JSONs in the repo are the contract connecting the two—and the same contract that, since Act 2, gave me governance, traceability, and peace of mind.

A lean startup isn't one that chooses between speed and quality. It's the one that builds the infrastructure to have both—and, with any luck, discovers that every solved problem was the stepping stone for the next.

CoreAutoCRM is already running in production across dozens of auto shops in Brazil. I built this engineering for my own ecosystem, but I know the pain of scaling automations on rigid servers keeps many CTOs and founders awake at night.

My main focus is the expansion of my SaaS, making my technical schedule almost non-existent. However, I've decided to open an exclusive time window this month to structure the architecture of only 2 operations that need to solve this exact problem: scale n8n to millions of requests, cut AWS/VPS costs by 90%, and drop latency to milliseconds.

If your n8n is eating up your server's memory, if your automation crashes during peak hours, or if you've simply hit the Low-Code ceiling and don't want to rewrite everything from scratch, text me on WhatsApp ([https://wa.me/556296232227](https://wa.me/556296232227)). Let's do a quick X-ray of your infrastructure and solve this.
