Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

How many rows a page is sent

live var rows = Order.OrderBy(o => o.PlacedAt).Take(50).ToList(); // the Take is the page's ceiling

A page's query is sent bounded. Its own .Take(n) is the ceiling: the browser may ask for fewer of those rows, never more. A page query with no Take is sent about 2 MB of rows per answer, and the answer says when more exist, so a Select of the few fields a page shows gets more rows than whole rows do. A direct call (the REST API, the context data route, the MCP tools) gets 50 rows unless it asks for a page size, and at most what fits about 2 MB. Code that runs on the server is never capped: a function that sums every invoice sees every invoice.

stable2 examples compiled by CIuiquerydatalimits

Summary#

Rows that leave the server for a browser are always bounded, so no page and no caller can ask for a whole table in one answer. You choose the bound with .Take(n). When you choose none, a page gets about 2 MB of rows per answer and is told whether there are more; a direct call gets 50 unless it asks for a page size, and never more than fits about 2 MB. The limit is the answer's size, not a row count, so narrow rows go further: a page that Selects the three fields it shows gets several thousand rows in one answer where whole rows of a wide entity get about a thousand.

entity Ticket {
  [Required, MaxLength(120)] string Subject;
  DateTime OpenedAt;
  bool Closed = false;
  security { allow read, create, update when IsAnonymous || IsAuthenticated; }
}

[Route("/tickets")] [Render(CSR)] [AllowAnonymous]
component TicketsPage() {
  live var open = Ticket.Where(t => !t.Closed).OrderByDescending(t => t.OpenedAt).Take(50).ToList();
  live var openCount = Ticket.Count(t => !t.Closed);

  render {
    Stack(gap: 2, p: 6) {
      Text($"{openCount} open — the newest {open.Count} shown");
      foreach (var t in open) { Text(t.Subject); }
    }
  }
}

open is sent at most 50 rows, whatever the browser asks for. openCount is an aggregate, answered over every row: a count, a sum or an average is never windowed, because a window would make it wrong rather than cheaper.

Signature#

Entity.….Take(n).ToList()                     // a page query: n is the ceiling the browser cannot raise
Entity.….ToList()                             // no Take: about 2 MB of rows an answer, and the answer says more exist
Entity.….Select(x => new Row { … }).ToList()  // the same, and only the named fields: more rows fit one answer
Entity.Count(…) / .Sum(…)                     // an aggregate: over every row, never windowed

Description#

What does a page query send?#

Three bounds apply at once, and the answer holds the smallest:

boundwhere it comes fromcan the browser raise it?
the author's .Take(n)your sourceno — it is the ceiling of the whole set
the browser's windowa list paging through the setit can only ask for less
about 2 MB of rowsthe platformno — one answer never carries more

So a query written .Take(20) sends at most 20 rows even to a hand-made request asking for a hundred thousand. A query written with no .Take sends rows until the next one would take the answer past about 2 MB, and its answer carries total (how many rows the set holds) and hasMore: true. The browser's query handle reads both: hasMore is true, and loading more fetches the next answer from the row after the last one sent — whole rows and projected rows (.Select(o => new Row { … })) alike, so paging never skips or repeats a row.

How many rows that is depends on the rows. A whole row carries every field its entity declares; a projection carries only the fields it names, and the database reads only those columns:

what the page readsbytes a row (measured, a 71-field order)rows in one answer
the whole row1,813about 1,150
Select into a three-field class353about 5,900

A row larger than 2 MB on its own is still sent, alone, so a read never stalls.

The author's .Skip(n) is where the set starts: a browser paging through it counts from there.

In Osy# the same two facts are members of the list: rows.HasMore says whether the set holds rows past what the page has, and rows.LoadMore() reads the next answer and appends it (Lists that load more). DataGrid pages by itself, and LoadMoreButton(rows) puts the same offer under a list drawn with foreach. A list nothing pages draws the first answer's rows and looks complete — that is why osy lint asks you to page it or choose the bound (A list or table with no bound), and why it suggests a Select when a page reads a wide entity to show a few fields (Select the fields a page shows). A kit's pages are asked the same: osy lint in a kit's folder or in its test project reports them under the kit's files, since every app that installs the kit is served its reads.

What does a direct call send?#

The REST API's entity routes ($top, $skip), the context data route (?pageSize=, ?page=, ?skip=) and the MCP query tools (top, skip) share one rule: 50 rows when the caller names no size — including a request with no options at all — and never more than fits the answer budget. The page size a caller may ask is as many rows as fit about 2 MB at the entity's estimated row size; a larger ask is served at that size. The rows are measured as they are sent, so rows larger than the estimate end the answer early. Every answer says what it used and whether more exist:

fieldmeans
totalhow many rows match
top / pageSizethe page size the server used — less than you asked for when you asked past the cap
hasMorerows exist past this answer

$skip and ?skip= are row offsets: $skip=10&$top=50 answers rows 10–59. An answer that holds fewer rows than the page size it used and says hasMore was ended by its size: continue it with skip set to the rows read so far, not with the next page index.

What is never capped?#

Code that runs on the server. A function, a workflow step or a test reads every row its query names, because a sum over every invoice must see every invoice:

int OpenTicketsTotal() {
  return Ticket.Where(t => !t.Closed).ToList().Count;
}

An export of every row belongs in a server function for the same reason: a page query is capped, so an export built from one would stop where the first answer does.

Why not let a page ask for everything?#

Because the number of rows was never a number anyone chose. A page that reads a whole table is fast on a laptop and falls over on real data — and one answer carrying a whole table costs the server memory and the reader a long wait for rows nobody will scroll to. The bound is a size because size is what an answer costs: a thousand narrow rows are cheap and a thousand rows carrying long text are not, and a row count treats them the same.

See also#

Related

Lists that load more

A page's list over a big table holds the first answer's rows (about 2 MB of them). rows.HasMore says whether the set…

A list or table with no bound

osy lint's ui-unbounded-list-does-not-page (SHOULD) fires when a page draws a list with foreach from a query that reads…

A picker over every row — the SearchPicker

A Dropdown is usable for a few dozen options; over an entity that grows, past the first answer (about 2 MB of rows) the…

A chart over every row

osy lint's ui-chart-over-unbounded-rows (SHOULD) fires when a chart or a map is drawn from a page query that reads…

A page query with no bound

osy lint's ui-page-query-without-a-take (CONSIDER) is the catch-all: a page query reads every row of an entity with no…

Select the fields a page shows

osy lint's cost-page-ships-whole-rows-for-few-fields (CONSIDER) fires when a page reads whole rows of a wide entity and…

The reactivity & lifecycle model

How an Osy# component comes alive and stays in sync: declarations are live value bindings, `on mount`/`on unmount` are…

Acting on data — a calm grid header, filters as chips, and a toolbar that follows the selection

A DataGrid reads calmly: its per-column filter row is shut until somebody asks to filter (the Filter toggle, or a…