The demo table had 412 rows. It loaded in under a second. Everyone in the room nodded.
Eight months later, that same customer's work order table had 4.1 million rows. The list view took fourteen seconds to draw its first page. The customer's operations manager opened a support ticket with one sentence: "The system is slow."
The interesting part is not that a platform got slow. It is where it got slow, and why that place was inevitable.
The list view is where every abstraction converges
A form is one record. A workflow is one instance. A dashboard is an aggregate someone designed to be cheap.
A list view is different. It is the one screen where everything the platform promised has to be resolved at the same time: the field definitions, the relationships between tables, the permission rules, the workflow status, the computed columns, the saved filters, the sort order, and the user's own idea of what "the important records" are.
In a demo, all of those layers are thin. Twelve fields, one related table, two hundred rows, one user with full access, no saved filters, default sort.
In production, the same screen has eighty fields across four related tables, a row-level permission rule that depends on the user's department, and a saved filter someone created two years ago that nobody has looked at since.
The design did not change. The definition of "fast" did.
The four walls, in the order you hit them
Filter translation. In the interface, a filter says "Amount is greater than 10000." In the database, it says nothing, because the platform does not own physical columns — it owns metadata. It has to compile user intent into a query. That compilation is where the first bug lives, because a field's type is not fixed. The same logical field can be numeric in one application and text in another, and the string comparison and the numeric comparison return different records. A filter that silently returns the wrong rows is worse than a filter that is slow, because slow gets reported and wrong does not.
Permission-aware filtering. This is the one that breaks the performance model permanently. In a low-code platform, permissions are not a wrapper around the query. They are part of the query. If a user can only see records in their own department, then every count, every page, every aggregate, and every export must carry that restriction.
The tempting shortcut is to fetch and then filter in application code. It works beautifully at four hundred rows and catastrophically at four million, and it introduces a quieter failure: the pagination gets confused. Total count says two thousand, the user sees sixty records per page, and page nine is empty. Then someone builds a report on the same numbers and the report disagrees with the screen, and now you are debugging a trust problem, not a latency problem.
Related field display. Showing "Customer Name" on a list of orders is a join. Most platforms hide this until the customer asks for their fifth related column, and then it becomes a join graph. The natural implementation is one query per row, which is elegant and dies at scale. The natural correction is one enormous query with twenty joins, which the planner may handle, until the day it does not.
Sorting and computed columns. Sorting by a stored, indexed column is cheap. Sorting by a computed column — a formula, a rollup from a child table, a workflow-derived status — means the database cannot use an index at all. It must evaluate the expression for every candidate row before it can decide what the first page is. At ten thousand rows nobody notices. At a million rows the sort is the query.
The part that took me too long to accept
Performance work in a low-code platform is not a database administration problem. It is a metadata problem.
You cannot hand your slow queries to a DBA, because at the moment the customer complains, the query does not exist yet. It gets generated at runtime out of a form definition, a permission rule set, and a filter someone saved in 2024. There is no query to tune.
So you end up building a small query planner. You end up with a cost model that knows the shape of the permission rule, because a permission filter that resolves to an indexed column is a different animal from one that resolves to a subquery over an access graph. You end up with a plan cache, because compiling metadata to a query is not free and doing it on every request is absurd. You end up with a concept of which view configurations are "materializable" and which must be computed live.
Nobody puts "build a miniature database optimizer" on a low-code platform roadmap. It shows up anyway.
What actually moved the needle
Four things, in order of impact.
Stop assembling the view at request time. Compile it. The combination of view definition, permission shape, and filter set is a query plan, and it can be built once and cached. The list view became a compiled artifact rather than a runtime negotiation.
Make the permission filter cheap. Precompute the visible set, or denormalize the scope into a column that can be indexed. The rule should resolve to a seek, not a traversal. This single change took more time off the worst screens than any index we added.
Admit that two engines exist. An interactive list view and a data export have completely different jobs. One must return a first page in a few hundred milliseconds, because a human is watching. The other may legitimately take thirty seconds, because nobody is. Trying to run both through one code path is how you get a system that is slow at three hundred rows and times out at ten million.
Stop promising deep pagination. Offset pagination into the millions is a promise no database keeps. Deep pages need a cursor based on the sort key, not an arithmetic skip.
And one thing that is not performance work at all but changed how users felt about the platform: telling the truth in the interface. A message saying the filter matched an enormous number of records and should be narrowed is a better experience than a spinner, even when both take three seconds. Users forgive slow. They do not forgive unexplained slow.
The agent made it worse before it made it better
We added the ability for an AI agent to query, create, edit, and delete records against these same tables. It is genuinely useful. It is also the most aggressive list view user you will ever have.
A human scrolling a slow table gives up after two pages. An agent does not give up, and it does not understand that asking for "all matching records" on a table with four million rows is a different request from asking for the first twenty. Almost everything we learned about interactive performance had to be re-learned as a machine-facing contract: hard limits, mandatory cursors, and a refusal to answer unbounded questions.
The uncomfortable conclusion
Low-code platforms are sold on the promise that you no longer need to think about databases. That promise is real for the customer, and it is a lie for us.
Someone still has to think about indexes, cardinality, execution plans, write amplification, and permission scope acting as an access path. The only thing the platform changes is who: it cannot be the person building the application. It has to be the platform itself, permanently, on behalf of users who will never know there was a decision to make.
Which means every abstraction we sell is a debt instrument. The form engine, the permission system, the workflow engine, the plugin mechanism — each one buys simplicity at the top of the stack and pays for it in complexity at the bottom. The list view is simply where the bill arrives, and it arrives in year two, when the platform is load-bearing, the original demo is forgotten, and nobody remembers that the first version was fast because the table was small.
I used to think of performance as a phase. Something you do after the features work.
It is not a phase. It is the cost of the abstraction, and it comes due every time a customer's business succeeds enough to produce real data.
Top comments (0)