The first message was three words long: "Saving is frozen."
It came from a production planner at a components manufacturer, forwarded by her IT lead with no commentary. She had opened a work order, changed one quantity, and pressed save. The button had been spinning for eleven minutes. She tried it three times, then stopped, because the second attempt made her nervous.
By the time we got on a call, two things were true. Her save had not failed — it was still running. And nobody else in the plant could edit that work order, or the four records downstream of it. The system was not down. It was busy in a way that looked exactly like being down.
The cause took an afternoon to find and four seconds to explain. The work order's automation recalculated a bill of materials, looping over every component line and calling a partner API for current pricing. Nine hundred lines, roughly three seconds per call. Forty-five minutes of work, wrapped in a single database transaction, holding locks the whole time, because of a boundary nobody had ever drawn.
The transaction is bigger than the drawing
In our platform, the transaction boundary is not something the builder chooses.
An automation is one transaction. All of it. The documentation is unambiguous — a complete automation runs inside a single transaction, and if anything throws, or the flow is stopped, the whole thing rolls back. It goes further: the before-create event, the creation, and the after-create event of a single step are also one transaction, so aborting anywhere in that chain discards the creation itself. The listener model matches. Listeners — the rules that fire on record creation, on data change, on user action — execute in the same transaction as the thing that triggered them. A listener that throws rolls back the form submission that woke it up.
None of that is wrong. Atomicity is not a bug. But look at what it means in a visual builder. The person who drew that recalculation was drawing logic — sequence, branches, API responses. They were not drawing a transaction, and nothing on the canvas told them they were. The transaction is inferred from the shape of a graph they built for an entirely different reason.
The graph is precise. The transaction is a side effect.
The number nobody computes
Our own documentation contains the clearest illustration of this failure I have read, and it is offered as friendly advice.
The example is two data tables, two thousand records each, and a nested loop with an HTTP request at the innermost level. Average request time: three seconds. The documentation does the arithmetic honestly — two thousand times two thousand times three seconds is more than thirty hours — then says what you would expect, that a flow like this running frequently is a disaster for the system, and recommends estimating execution time before you build.
That paragraph is correct and I am glad it exists. It is also a confession. A thirty-hour transaction is not something the platform prevented. It is something the platform documented. The canvas renders a nested loop as a modest little box, the same size as a single API call. There is no meter, no warning, no estimate, no ceiling. We outsourced the work of noticing to the person with the least information about what it costs.
There is a transaction timeout in the API — thirty seconds, if you set it — but you can only set it from script. Which brings us to the real problem.
Everything that governs a transaction lives where the builder cannot see
Our script API for transactions is more complete than I remembered. Seven propagation modes, including nested and requires-new. Isolation levels from read-uncommitted up to serializable. A timeout. A read-only flag. Transaction synchronizations — hooks that fire before commit, after commit, after completion — exactly the mechanism you would use to send a notification after the data is safe rather than inside the lock.
There is even a name for the transaction, and the documentation explains its purpose: a name helps developers identify and track transactions in logs and monitoring tools.
Count how many transactions in a real customer application have names. Approximately none. Not because the feature is missing, but because there is no moment in the visual workflow where a transaction becomes a thing a person is asked to think about. It is a constructor you must know exists before you can name it, in a document you only read after something has already gone wrong.
The one hint that reliably reaches builders is a tip telling them to paste three lines of script if they want a step to open a new transaction — a curious way to teach someone that they were inside one all along.
The scheduler has one lane, and it deletes the future
Transactions do not only come from user actions. They come from scheduled work, and the scheduling model has the same shape of problem.
Scheduled tasks fire at one-minute precision, and at any given moment exactly one is running. Consider a job configured every five minutes that takes six to finish. The run scheduled for minute five does not queue and does not wait. It is cancelled. A slow job does not merely finish late; it quietly accumulates runs that never happened. And after an outage, the misfire policy offers two choices: execute everything that was missed, or throw all of it away. Neither answers the question, which is whether a job that runs long should be allowed to run long, run chunked, or run at all.
The first time I watched a nightly reconciliation silently lose two cycles because one cycle was slow, I stopped thinking of schedules as triggers and started thinking of them as a single-lane road with no shoulders.
Termination is a person, and it is a destructive act
The runtime page does try. It lists what is executing right now — scripts, scheduled jobs, automations, API calls — and lets you terminate one by hand. The documentation names the reasons you would need to. Long-running tasks. Slow execution. Infinite loops. Database deadlocks.
Read that list again as a design document. Those are the system's own admitted failure modes, and the remedy for all four is a human noticing and clicking a button. Now consider what the button does. It does not pause and it does not checkpoint. It rolls back — which can mean discarding forty-five minutes of calculation, plus every record a colleague created inside a listener that shared the transaction, plus the save the user believed had succeeded.
A platform whose only answer to "this is taking too long" is "have someone destroy it" has not finished designing the feature.
Abort is the only word we gave you for no
Because listeners share the trigger's transaction, the way you implement a business rule at the data layer is to abort. Rule violated, flow stopped, everything rolls back. There is no vocabulary for reject this field but keep the record, no partial acceptance. The state machine has two states: it all happened, or none of it did. Builders who need a third state invent it outside the platform, and the validation they were asked to implement quietly becomes a reliability hazard instead of a guardrail.
And when a script reaches across to an external ERP over a raw JDBC connection, holds it in manual-commit mode, writes to both systems, and commits once — there is no coordinator. Two databases, one commit call, no two-phase protocol. The transaction that spans two systems is the most optimistic object in the building.
The uncomfortable conclusion
Every other finite resource here has a visible number. Storage is gigabytes. Tables have row counts. Usage points are metered and show up on an invoice. Customers can see the ceiling and argue about where it should be.
Transaction duration has no number at all, because on the canvas a transaction is not a thing that exists. It is a consequence of a diagram. So the most expensive decision in the system — how long we hold a lock on your data, and therefore how many other people are standing behind you — is a decision nobody is ever asked to make.
We moved application development up the stack, into a canvas a process analyst can read. Transactions stayed down in milliseconds and isolation levels and lock waits. We never built the bridge.
Which is why "why is saving frozen?" took an afternoon to answer out of pieces that were all in the documentation. The platform knew.
The most expensive thing in a low-code platform is not a feature you chose not to build. It is a duration you never knew you were choosing.
Top comments (0)