There’s a demo in the Lab I really like: you fill in a test contact and watch, step by step, how a workflow validates it, scores it, decides what to do with it and drafts the reply. Live automation started out with n8n actually running on a server, and it worked. The problem was time: almost two minutes could go by between the click and the first step.
Here’s how I got it under 2 seconds without giving up n8n, and how I make sure the site never drifts from the workflow I designed.
Where it started: real n8n on Render Free
The first version (DEC-021) put n8n on Render’s free plan as one more service. It was a stateless n8n: the workflow was versioned in the repository and imported on every start, and the editor and the public API were closed to the internet. The site handed it each contact, and n8n reported the steps back.
All of that worked, but Render’s free plan puts a service to sleep after 15 minutes without traffic. On 2026-11-07 I measured what that meant, with the same Docker image and one contact going through the whole workflow:
- With 0.1 CPU, like Render Free: about 72 s to be ready locally and 112 s on Render; the first run, about 14 s; the next ones, about 2 s.
- With 0.5 CPU, like Render Starter: about 14 s to be ready; the first run, about 2.1 s; the next ones, 0.3 to 1 s.
- With 1 CPU: about 17 s to be ready; the first run, about 1.4 s; the next ones, 0.2 to 0.5 s.
In other words: 70 to 112 seconds just to wake up and, once awake, about 2 seconds per run, because 0.1 CPU can’t do more. A demo that makes you wait two minutes doesn’t prove anything: hardly anyone stays to watch it.
The goal: under 2 seconds, from click to result
With those numbers the decision was clear (DEC-023). To get under 2 seconds with n8n live, I’d need an always-on server with 0.5 CPU or more. I looked at three ways to keep n8n live: Render Starter (US$7 a month), free Hugging Face Spaces with a daily ping so it doesn’t sleep, and a free Oracle VM. All three cost money, depend on a ping or make me maintain a server.
The other option was to split two things that had been tied together until then: where the workflow is designed and where it runs. n8n is still the design tool; the site runs the workflow on Cloudflare, in the same function that already serves its API.
n8n designs, the edge runs
The workflow exported from n8n, lead-demo.json, is still the source of truth. The @aim/automations package reads the workflow’s name from that JSON, and the Reglas del flujo (“workflow rules”) node, which holds every business rule in one place: what counts as a business email, how many points each budget adds, the tiers, the routes and the email templates.
function readRules(source: Workflow): LeadFlowRules {
const node = source.nodes.find((candidate) => candidate.name === RULES_NODE)
const json = node?.parameters.jsonOutput
if (typeof json !== 'string') throw new Error(`n8n workflow: node "${RULES_NODE}" has no JSON`)
return LeadFlowRules.parse(JSON.parse(json))
}
So changing a rule in n8n and exporting it changes the site without touching code. If the JSON is missing a rule the workflow needs, Zod rejects it on load, and CI catches it before anything ships.
The logic of the other nodes lives in lead-flow.ts, which runs the workflow node by node, the way n8n evaluates it. Each block names the node it mirrors, quirks included. For example, the «Calcular nivel» (“compute tier”) node caps the score at 100 but picks the tier from the uncapped sum, and the engine does exactly the same:
/** "Calcular nivel": the score is clamped to 0–max; the tier uses the raw sum, as the node does. */
function grade(R: LeadFlowRules, list: AutomationFactor[]): { score: number; tier: AutomationTier } {
const sum = list.reduce((total, factor) => total + factor.points, 0)
const { tiers } = R.scoring
return {
score: Math.max(0, Math.min(R.scoring.max, sum)),
tier: sum >= tiers.hot ? 'hot' : sum >= tiers.warm ? 'warm' : 'cold',
}
}
To make this possible, the workflow avoids the Code node: the engine would have to mirror arbitrary code. It uses Set and IF nodes with short expressions, which can be mirrored line by line.
How do I know the engine does what n8n does?
This is the part that gives me peace of mind. automations/test/fixtures/n8n-runs.json holds what real n8n, the same Docker image, reported for 11 test contacts, from a hot one and one rejected for its links to one with emojis and line breaks. CI requires the edge engine to return exactly the same steps with the same data. The only difference allowed is the first step, received: n8n put its execution number there, and the edge puts the workflow’s name.
One detail was missing: what if I change the workflow in n8n and forget to record again? The recording keeps a fingerprint of the workflow, a hash of the nodes and their connections, without the sticky notes or where the nodes sit on the canvas:
export function fingerprint(workflow) {
const nodes = workflow.nodes
.filter((node) => node.type !== 'n8n-nodes-base.stickyNote')
.map(({ name, type, typeVersion, parameters }) => ({ name, type, typeVersion, parameters }))
.sort((a, b) => (a.name < b.name ? -1 : 1))
const json = JSON.stringify(sorted({ nodes, connections: workflow.connections }))
return createHash('sha256').update(json).digest('hex').slice(0, 16)
}
And a test compares that fingerprint with the current workflow’s:
it('was recorded from the current workflow', () => {
// Fails after a change to a node or a connection: record again (automations/README.md).
expect(recording.fingerprint).toBe(fingerprint(leadDemoWorkflow))
})
If I move a node or edit a note, nothing changes. If I change an expression or a connection, the test fails until I record again with pnpm --filter @aim/automations record:n8n, which starts n8n in Docker and sends it the test contacts (it takes about a minute). And if the logic changed, not just the rules, parity fails until I adjust lead-flow.ts.
Neon left the path too
Measuring in production brought another surprise. While n8n ran on Render, the API counted each run in Neon to protect its free hours. Just opening that connection and counting took about 1.25 s, and 2.1 s with Neon asleep: more than the whole rest of the demo.
With the workflow on the edge there’s nothing left to protect. A run costs one Turnstile check and under 1 ms of CPU, and nothing is sent or stored. So the database left the path: migration 0005 dropped the automation tables, and the protection against bots is Turnstile, with a single-use token for each run. A bot gains nothing by repeating it.
What you see today
The browser sends the contact to POST /api/automation/runs. The edge checks Turnstile, runs the workflow in memory and streams the steps back with Server-Sent Events. I checked it in production on 2026-11-07: the API answers in about 40 ms, Turnstile check included. The page measures the time that matters to you: from your click to the result, in your browser.
There was a cost, and I’ll say it plainly: the page can no longer say that n8n runs the workflow live. It says the workflow was designed in n8n and the site runs it with the same rules, and it links to the JSON on GitHub. If I ever pay for an always-on server, the previous protocol is still in the git history.
Check it yourself
- The full explanation of the protocol and the parity check (in Spanish):
docs/AUTOMATIZACION.md. - The engine and the test:
automations/src/lead-flow.tsandautomations/test/parity.test.ts. - How to edit the workflow in n8n and record again (in Spanish):
automations/README.md. - The API route on the edge:
apps/edge/src/routes/automation.ts.
And the best part is trying it in the Lab. Use test data: nothing is stored, and the email is only shown.