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 windowedDescription#
What does a page query send?#
Three bounds apply at once, and the answer holds the smallest:
| bound | where it comes from | can the browser raise it? |
|---|---|---|
the author's .Take(n) | your source | no — it is the ceiling of the whole set |
| the browser's window | a list paging through the set | it can only ask for less |
| about 2 MB of rows | the platform | no — 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 reads | bytes a row (measured, a 71-field order) | rows in one answer |
|---|---|---|
| the whole row | 1,813 | about 1,150 |
Select into a three-field class | 353 | about 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:
| field | means |
|---|---|
total | how many rows match |
top / pageSize | the page size the server used — less than you asked for when you asked past the cap |
hasMore | rows 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#
- Lists that load more —
rows.HasMore,rows.LoadMore(), and the controls that page - Select the fields a page shows — select the fields a page shows, and fit more rows in one answer
- A list or table with no bound — a list or table with no bound
- A picker over every row — the SearchPicker — a picker over every row:
SearchPicker - A chart over every row — a chart over every row
- A page query with no bound — any other page query with no bound
- The reactivity & lifecycle model —
live varand when a page re-reads