Asked for 100, Got 20: The Counter Said 54
The limit you send is a hope, not a contract. A list endpoint silently clamped limit 100 down to 20, so a counter reading 54 rendered 20 cards.
TL;DR
The KotaPortal portal displays 20 cards despite a reported total of 54. The server limits requests to 50 and silently reduces larger limits to 20. The fix involves fetching one page at a time.
The number above the list indicates 54 sports organizations. However, only 20 cards actually appear on the screen. The KotaPortal portal does display a counter from the total reported by the server, and that number is indeed 54. The initial suspicion at that time was normal: the page failed to retrieve all the data, so the request limit was increased to the maximum. The number was already limit: 100, which should have been more than enough.
It turned out that the request was never received in its entirety.
Counter says 54, screen shows 20
The service that handles the list is located in api/internal/organisasi/service.go, and its two functions, List and ListAdmin, contain the same lines:
if q.Limit < 1 || q.Limit > 50 {
q.Limit = 20
}
limit: 100 is above 50, so it falls back to 20. Not 50, and there is no error: the response remains 200 OK with 20 rows of data.
limit: 100 is above 50, so it falls back to 20. Not 50, and there is no error: the response remains 200 OK with 20 rows of data. The OpenAPI contract for the public list has specified the limit from the beginning, { name: limit, schema: { type: integer, default: 20, maximum: 50 } } — the number 100 is indeed outside the contract, and the server chooses to reduce it silently instead of rejecting it.
This behaviour is not unique to one project. AIP-158 mentions two things that happen exactly here: the value of page_size above the limit "coerce down to the maximum permitted page size" [1], and an API is entitled to "return fewer results than the number requested" [1]. GitHub REST also works in the same way, "GitHub will paginate the results and return a subset of the results" [2], and then the next page is taken from the link header, not from increasing per_page. This means that the number sent by the client is just a hoped-for number.
In fact, the response itself is already honest. The handler passes the clamped limit into the meta:
response.Paginated(w, data, int64(listQ.Page), int64(listQ.Limit), int64(total))
So meta.limit has a value of 20, not 100. The client receives the correct answer, then ignores it.
Read the response, not the request
The fix is in frontend/src/components/features/organisasi/cabor-list.tsx. Instead of one large request, the component now fetches one full page at a time and stops at three conditions:
const PAGE_SIZE = 50;
for (let page = 1; ; page++) {
// other filters (category, type, search) go on every request
const res = await listOrganizations({ page, limit: PAGE_SIZE });
if (cancelled) return;
const batch = res?.data ?? [];
accumulated.push(...batch);
totalCount = res?.meta?.total ?? accumulated.length;
if (accumulated.length >= totalCount || batch.length < PAGE_SIZE) break;
}
The first condition stops when the accumulation has reached meta.total. The second condition catches the last page that has fewer than PAGE_SIZE, and at the same time becomes a safeguard if meta.total turns out to be inconsistent with the response contents. The third condition, an empty page, ensures the loop does not run indefinitely when the filter changes in the middle of the search. For 54 rows, two requests are already enough.
This multi-request loop has another problem: the user can switch tabs or type again before the first request is complete. The fix is a single flag in the effect cleanup:
let cancelled = false;
// ... every setState is guarded by if (cancelled) return;
return () => { cancelled = true; clearTimeout(timer); };
React runs the cleanup before the next setup, and in development Strict Mode even runs one additional setup+cleanup cycle [3]. Without this flag, the old tab's data could overwrite the new tab's data after everything is finished. The 350ms debounce is still maintained because the search should not flood the server with requests per character.
The symptoms are easy to check without opening the source code. Open the Network tab in the browser, send a list request with a large limit, and then compare three numbers: the length of the data array, meta.limit, and meta.total. If the data length is the same as meta.limit while the total is much larger, the request has indeed been returned to the server limit, and the only way out is to follow the provided pages. The same case appears in almost all filtered lists, including searches that type per character.
The lesson learned is not about the numbers 20 or 50. meta.total is a hint, not a receipt; and a limit is a contract between the client and server, not a personal expectation. Next.js calls this pattern a client data waterfall problem that is difficult to manage [4], and for datasets that are much larger, the answer is indeed SWR or TanStack Query, not a manual loop. For lists of dozens of rows, two requests that stop at the right place are a smaller and more honest tool.