Pagination audit and rollout plan
A source-based audit of collection endpoints, experimental UI overfetch, and an additive pagination rollout that leaves the legacy API unchanged.
This report records the API's pagination and request-safety gaps as of 2026-08-30. It focuses on collection endpoints used by the experimental UI and on shared API behavior that affects those endpoints.
The decision is straightforward: do not retrofit new defaults or response
envelopes onto existing routes. The Go service promises to preserve legacy
paths, query parameters, status codes, and JSON shapes
(source). Add a coherent
/api/v2 collection contract, migrate one experimental screen at a time, and
keep every existing route available with its current behavior.
Executive finding
The API supports pagination, but not as a platform capability. Individual
handlers independently implement page/pageSize, page/limit,
page/perPage, or limit/offset; other collection handlers return every
row. Some paginated responses bound the SQL query, while others fetch and
enrich the complete candidate set before slicing it in Go.
The application has useful failure containment—panic recovery, request logs,
HTTP timeouts, a bounded database pool, and several route caches—but no
application-level rate limiter. A deployed host has returned 429 during
contract recording, which proves that some outer layer can throttle traffic;
it does not define a quota that API clients can rely on
(source,
source).
The overfetch observed in the experimental UI is therefore a combination of three separate problems:
- the UI requests too many rows;
- several endpoints return fields the screen never renders; and
- several handlers still perform work for rows that pagination later removes.
A correct rollout must address all three.
Numbered problem inventory
Existing behavior is already a public contract
The Go API is a route-for-route port. Even an apparently safe change such as
making /api/player return { data, pagination } instead of a bare array, or
changing the default size on /api/referees/list, can break a caller. Current
quirks are deliberately preserved elsewhere in the port, including response
types and error branches
(source,
source).
Consequence: no existing path, parameter name, default, coercion rule, status, sort order, null behavior, numeric type, or JSON shape may be changed as part of this project.
Pagination has four request dialects and several response dialects
Examples include:
/api/event:limitandoffset(source);/api/value-bets:pageandperPage(source);/api/lineups/players:pageandpageSize(source);/api/referees/list:pageandlimit(source); and/api/props/team-screener:limitandoffset(source).
The response metadata also varies. /api/event reports limit, offset, and
the current row count; most boards report currentPage, totalPages, and
totalCount; Hunter reports page, limit, and total. The web app has begun
centralizing the common five-field shape, but pageSize remains optional
because the endpoints disagree
(source).
Consequence: every consumer needs route-specific knowledge, and generic pagination controls cannot trust one contract.
Several limits have no maximum
/api/referees/list defaults to 500 and accepts any positive limit
(source).
/api/props/team-screener defaults to 250 and accepts an uncapped positive
floating-point limit
(source).
/api/props/outliers defaults to 1,000 and has no ceiling
(source).
Hunter defaults to 50 without clamping the supplied value
(source).
Consequence: one request can turn a normally bounded response into a large allocation, a long serialization, and a wide database query.
A paginated response does not necessarily bound database work
The lineups handler explicitly fetches every candidate starter, loads position history for that set, filters in memory, and only then slices the response (source, source). Outliers fetches every qualifying bookmaker row, groups and sorts all markets, then applies the page (source, source). Player trends and team trends cap their initial reads at 2,000 rows but enrich and sort the full result before slicing (source, source, source, source).
/api/value-bets-v2 has a similar documented constraint: it fetches every
qualifying group so it can recompute and sort by the displayed edge before
paging
(source,
source).
Consequence: reducing the page size may save response bytes without reducing the dominant CPU, memory, or database cost.
Some collection endpoints are unbounded
The generic list helper reads every row and backs /api/player, /api/team,
and several reference endpoints
(source,
source,
source,
source).
The league player-stat endpoint groups every player in a season and generates
three aggregates for every statistic column, with no page or projection
(source,
source).
The data visualizer returns every fixture in a requested date range and runs
three correlated count subqueries per fixture
(source,
source).
Consequence: dataset growth changes response size and query cost without any contractual ceiling.
Hard limits are sometimes mistaken for pagination
Search returns at most ten fixtures, teams, players, and tournaments per entity
type, but it cannot fetch a second page
(source,
source). Other
limit parameters select a historical sample—such as a player's last matches—
rather than page a collection.
Consequence: a mechanical rename of every limit parameter would corrupt
endpoint semantics. The migration inventory must classify what is being
limited before changing anything.
Offset pagination lacks deterministic tie-breakers
/api/event orders only by timestamp
(source). The
referee board orders by whether a next game exists and the selected metric, but
not by referee ID
(source).
Outliers sort only by computed edge after grouping
(source).
Consequence: equal sort values can move between requests, causing duplicate or missing rows across adjacent pages. Inserts between page requests can still shift offset pages even with a tie-breaker; stable ordering prevents the avoidable half of the problem.
Page metadata is not always computed from the rows a caller sees
The v1 value-bets endpoint calculates totals before applying activity filters,
so a page can contain fewer rows than its metadata describes
(source,
source). The
lineups route clamps an out-of-range request to the last page instead of
returning an empty page
(source).
Empty-result routes disagree on whether totalPages is zero or one.
Consequence: these rules must stay on legacy routes, but the new contract must choose one behavior and test it.
The experimental UI fetches whole datasets for partial views
The league page fetches every season player and every generated aggregate, sorts them in JavaScript, then keeps twelve goal scorers (source). The referee page requests 500 rows, then requests card odds for every returned referee's next fixture (source). The 100 Club requests the screener's maximum 1,000 rows and renders the returned list (source, source).
Consequence: the browser, the Next.js server, and the Go API all move data that the visible screen does not require.
Summary screens download detail rows to compute aggregates
The data visualizer downloads a week or month of fixtures and summarizes the coverage in the client component (source, source). Super Sub Heroes returns nine top-50 leaderboards, while the screen reads only the active statistic (source, source, source).
Consequence: pagination alone is the wrong fix for summaries. These screens need aggregate endpoints or a requested-stat projection.
There is no application-level rate-limit contract
The router installs request IDs, real-IP handling, logging, panic recovery,
compression, cache defaults, and Cross-Origin Resource Sharing (CORS), but no
rate limiter
(source). The API
cannot currently promise a quota, return a consistent structured 429, or tell
a client when to retry.
Consequence: an edge provider may protect the host, but behavior can vary by deployment and is not testable as part of the API.
General safeguards exist, but the gaps align with large requests
Existing controls include:
- 10-second header and 60-second read/write HTTP timeouts, plus graceful shutdown (source);
- a database pool capped at ten connections, or one in serverless mode (source, source);
- structured request duration and response-byte logging (source);
- panic recovery (source); and
- request coalescing and a bounded cache for the expensive fixture-day query (source, source).
The shared JSON decoder does not cap request-body bytes (source). The shared TypeScript client accepts a caller-provided abort signal but supplies no default timeout or retry policy (source). The health endpoint reports process state without checking database readiness (source).
Consequence: large or stuck work can consume the bounded database pool until the outer HTTP timeout, while clients have no uniform retry guidance.
Endpoint inventory
This inventory covers collection endpoints and endpoints with pagination-like
parameters. It deliberately excludes detail routes where limit means “last N
matches” rather than “rows per page.” Generated OpenAPI currently exposes at
least one of page, pageSize, perPage, limit, or offset on 42 GET
operations, but the source determines whether each parameter is pagination.
Response and SQL are both paged
| Endpoint | Current request | Default / maximum | Work bound | Main issue |
|---|---|---|---|---|
/api/event | limit, offset | 50 / 200 | SQL LIMIT/OFFSET | No total; timestamp sort lacks ID tie-breaker |
/api/value-bets | page, perPage | 25 / 100 | Group-key query is paged | Post-filters can shrink the page after totals are calculated |
/api/props/screener | page, pageSize | 1,000 / 1,000; minimum 10 | SQL LIMIT/OFFSET before enrichment | Default is the maximum and much larger than the UI standard of 25 |
/api/props/team-screener | limit, offset | 250 / none | SQL LIMIT/OFFSET | No maximum; total count is serialized as text |
/api/referees/list | page, limit | 500 / none | SQL LIMIT/OFFSET | No maximum; unstable ties; broad aggregate row |
/api/props/hunter | page, limit | 50 / none | Aggregate query receives limit and offset | No maximum; empty response shape can omit pagination |
/api/team-shots-glm-predictions without positiveEv=true | page, perPage | 10 / 50 | SQL page before odds enrichment | Positive-EV mode changes the work model |
The limits and query placement above come directly from the event, screener, team screener, referee, Hunter, and generalized linear model (GLM) handlers (source, source, source, source, source, source, source, source).
Response is paged after broader work
| Endpoint | Current request | Default / maximum | Work before the slice | Required change in v2 |
|---|---|---|---|---|
/api/lineups/players | page, pageSize | 100 / 500 | All upcoming candidates and their history | Two-phase key query; page candidates before row projection when the filter permits |
/api/props/outliers | page, limit | 1,000 / none | All prices grouped and edge-sorted in Go | Compute the market edge in SQL or a materialized read model, then page keys |
/api/props/player-trends | page, pageSize | 25 / 200; 2,000-row work cap | All capped candidates enriched and sorted | Rank eligible keys first, then enrich one page |
/api/props/team-trends | page, pageSize | 50 / 200; 2,000-row work cap | All capped candidates enriched and sorted | Rank eligible keys first, then enrich one page |
/api/value-bets-v2 | page, perPage | 25 / 100 | Every qualifying group and its displayed edge | Persist or compute the displayed sort key so SQL can select the page |
/api/team-shots-glm-predictions?positiveEv=true | page, perPage | 10 / 50 | Every candidate is priced before in-memory filtering | Move edge calculation/filtering into SQL, then page |
Growing collections without pagination
| Endpoint family | Current behavior | Risk | v2 treatment |
|---|---|---|---|
/api/player, /api/team, /api/manager | Generic listAll; manager currently lists players | Unbounded growing tables and a preserved legacy quirk | Paginated directory DTOs; leave legacy routes untouched |
/api/category, /api/country, /api/season, /api/venue, /api/models, /api/incident, /api/unique-tournament | Generic full-table list | Size grows silently; response shapes vary | Classify each as growing or bounded reference data |
/api/unique-tournament/{id}/{season}/player-stats | Every player and every stat aggregate | Large SQL projection; UI uses a small leaderboard | Sorted, projected player leaderboard endpoint |
/api/data-visualizer | Every event in a day or date range | Three correlated counts per event; month-sized response | Paged detail endpoint plus aggregate summary endpoint |
/api/event/by-date | Every visible fixture with full nested event, tournament, category, and team objects | Broad rows on the highest-traffic query | Compact fixture-day DTO; paginate drill-down where needed |
/api/tournament | Every visible competition, grouped after loading | Usually bounded, but not contractually capped | Compact bounded directory with an explicit hard ceiling |
/api/props/super-sub-heroes | Nine leaderboards of up to 50 rows each | Screen uses one statistic | Require stat; page only that leaderboard |
/api/search | Hard limit 10 per entity type | Bounded but cannot continue | Keep bounded typeahead; add a separate paged search route only for full results |
The fixture-day response builds nested objects with entire database rows (source). The tournament handler loads every visible row before consolidating and grouping it (source, source). These are distinct from unlimited player/team directories: a small lookup directory can remain non-paged if its maximum cardinality is explicit and tested.
Backward-compatibility contract
The rollout must satisfy all of these rules:
- Existing routes remain mounted at the same paths.
- Existing query and body parameter names, defaults, parsing quirks, and out-of-range behavior do not change.
- Existing status codes, headers, cache behavior, field names, nesting, nulls, numeric strings, and array/object envelopes do not change.
- Existing ordering remains unchanged, including ordering that is not fully deterministic.
- Existing authentication and authorization behavior does not change.
- Existing generated OpenAPI operations stay present.
- No v1 route is silently redirected to a v2 handler.
- Internal clients move explicitly and can roll back to the old route.
This is stricter than source compatibility. It treats observable behavior as the contract because current callers may rely on undocumented details.
Additive v2 contract
Mount new collection routes under /api/v2. The existing route named
/api/value-bets-v2 remains where it is; its suffix is part of a resource name,
not the new namespace.
Request
All pageable v2 endpoints use:
| Parameter | Type | Rule |
|---|---|---|
page | integer | One-based; default 1; reject values below 1 with a structured 400 |
pageSize | integer | Default 25; maximum 100; expensive endpoints may advertise a lower maximum |
sortBy | string | Endpoint-specific allowlist; never interpolate caller input into SQL |
sortOrder | string | asc or desc; documented default per endpoint |
Filters keep endpoint-specific names. Pagination does not require a universal filter language.
Use numbered pages for experimental tables because the current UI exposes page numbers and exact totals. A future live feed may use a separate cursor contract, but one operation must not switch between page and cursor envelopes.
Response
Use the shape the experimental UI has already converged on, making pageSize
required in v2:
{
"data": [],
"pagination": {
"currentPage": 1,
"pageSize": 25,
"totalCount": 0,
"totalPages": 0,
"hasNextPage": false,
"hasPreviousPage": false
}
}Rules:
- An empty result has
totalPages: 0. - A valid page beyond the last page returns an empty
dataarray; it is not clamped to the last page. totalCountcounts rows after every requested filter.- Every sort ends with a unique tie-breaker: normally
id; for grouped boards, a documented composite key. - Count and page queries run against the same predicate. When consistency across both statements matters, use one statement with a window count or a read-only transaction at an appropriate snapshot.
- The page query applies
LIMIT/OFFSETbefore expensive enrichment. If the sort key is computed, expose it in SQL or a materialized read model so the database can rank keys first. - Cache keys include every filter, sort, page, page size, authorization scope, and representation variant.
Compact screen DTOs instead of arbitrary field selection
Do not add a global fields= parameter. It creates many implicit schemas,
complicates cache keys, and makes authorization review harder. Define typed,
screen-oriented data transfer objects (DTOs):
LeaguePlayerLeaderboardRowRefereeBoardRowDataQualityEventRowDataQualitySummarySuperSubLeaderboardRowFixtureDayRow
Each DTO should contain only fields rendered by the screen or required to build its links and accessible labels. OpenAPI then documents a finite contract, and unused-field checks can compare the DTO against the component props.
Implementation plan
Phase 0: Freeze and measure the legacy surface
- Record contract goldens for every legacy collection route before adding v2. Include defaults, malformed values, zero/negative values, the maximum, above the maximum, empty results, and past-the-end pages.
- Record the legacy database query count and normalized table access with the existing contract harness. It already supports query-log assertions (source).
- Capture response bytes, returned rows, rows scanned, database duration, p50, p95, and p99 for the experimental UI's actual request URLs.
- Mark every collection as one of: SQL paged, response-only paged, deliberately bounded directory, or unbounded.
Exit condition: every existing route has a frozen behavior fixture and a work classification.
Phase 1: Build shared v2 pagination primitives
Create an internal package that owns:
- parsing and validation of
pageandpageSize; - checked offset calculation that cannot overflow;
- per-endpoint maximums;
- the response metadata type;
- allowlisted sort resolution; and
- helpers for a stable final tie-breaker.
Mount /api/v2 separately from the legacy /api handlers. Do not refactor a v1
handler to call a v2 helper in the first change; sharing implementation before
parity is proven makes a supposedly additive change capable of altering legacy
behavior.
Exit condition: unit tests cover the complete parameter matrix and one small v2 endpoint proves the contract without changing a legacy golden.
Phase 2: Remove the experimental UI's largest overfetch first
Implement and migrate in this order:
- League leaderboard. Add a projected endpoint that sorts by an allowlisted statistic in SQL and returns 12 rows by default. Stop generating every aggregate for every season player when the screen only needs goals and basic identity.
- Referees. Add
todayOnlyas a server filter, request 25 rows, return a stable referee-ID tie-breaker, and fetch card odds only for fixture IDs on the current page. - 100 Club. Move from
pageSize=1000to 25-row pages and add the existing shared pagination control. This screen can initially use the current screener endpoint because it already pages in SQL; move it to a compact v2 DTO after the UI behavior is stable. - Data visualizer. Split detailed events from summaries. Page the daily or
custom-range detail table; calculate week/month totals, daily coverage, and
corrupted-event counts in SQL and return
DataQualitySummary. - Super Sub Heroes. Send the active
statto the server and return one paged leaderboard instead of nine top-50 lists. - Fixture day. Add a compact v2 read model for the fields the experimental fixture board renders. Keep grouped day summaries bounded; paginate only the expandable fixture list if a day exceeds the chosen ceiling.
Each migration keeps the old request implementation behind a short-lived feature flag. Compare v1 and v2 row identities and sort order in non-user-facing telemetry before switching the default, then retain a rollback path for one release.
Exit condition: no experimental screen downloads more rows than it renders or downloads detail rows solely to compute a summary.
Phase 3: Bound work, not only response size
For each response-only paged handler:
- write a first query that returns only ordered row keys plus the exact total;
- include the unique tie-breaker in that order;
- apply the page to the key query;
- enrich only those keys in bounded batch queries; and
- restore key order after enrichment without an N+1 query.
For computed sorts such as outlier edge, displayed value-bet edge, positive EV, and trend strength, choose one of:
- express the calculation in SQL;
- maintain a materialized read model refreshed when source rows change; or
- use a bounded candidate query with a documented proof that it cannot omit a higher-ranked result.
Do not claim that a route is properly paginated until the page size bounds its dominant enrichment work. A separate count may still examine all matching keys; index it and measure it independently.
Exit condition: query traces show that returned page size bounds projected and enriched rows, and no endpoint performs an N+1 query.
Phase 4: Cover remaining growing collections
Add paged v2 directories for players, teams, managers, incidents, and any other table whose cardinality grows with ingestion. Define compact sort/search DTOs rather than exposing full database models.
For categories, countries, seasons, visible tournaments, and other lookup data, choose deliberately:
- paginate it if cardinality can grow without a small business maximum; or
- document a hard maximum, enforce it in SQL, return a compact DTO, and test the maximum if the UI genuinely needs the complete directory.
“Pagination everywhere” should therefore mean every growing collection is paged and every unpaged collection is explicitly bounded, not that a country dropdown needs page controls.
Exit condition: no collection has an accidental unlimited scan or response.
Phase 5: Add rate and resource controls to v2
Introduce rate limiting at two layers:
- edge limits absorb obvious abuse before it reaches Go; and
- application limits enforce the documented contract by authenticated user or API key, falling back to IP only for anonymous traffic.
Classify routes by cost instead of giving every request equal weight. A cached directory read and a fresh trend aggregation should not consume the same budget. Derive actual quotas from Phase 0 traffic and latency rather than inventing numbers in the interface design.
Every v2 limit response must include:
- HTTP
429; Retry-After;- standard
RateLimit-*headers supported by the chosen gateway; and - the v2 error envelope with a stable
RATE_LIMITEDcode.
Also add, for v2 first:
- a maximum request-body size;
- per-route context deadlines shorter than the server write timeout;
- a concurrency semaphore for expensive aggregations;
- a database statement timeout for v2 transactions;
- a readiness probe that checks required dependencies; and
- a default client timeout with abort propagation and retries only for safe, idempotent requests.
Applying a normal quota to v1 would change observable behavior and violate the compatibility contract. Keep v1 outside the documented v2 quota initially. An outer emergency ceiling may still protect service availability, but it must be set high enough to act as incident containment, not ordinary client policy.
Exit condition: v2 publishes and tests its rate-limit response, and an expensive route cannot occupy the entire database pool indefinitely.
Phase 6: Deprecate usage, not compatibility
After every internal consumer has moved:
- mark the old collection operation deprecated in generated OpenAPI;
- measure remaining v1 traffic by route and client identity;
- stop adding features to v1; and
- keep v1 mounted until an explicit external deprecation project authorizes removal.
This pagination project does not remove legacy endpoints.
Validation matrix
Contract tests
- All existing goldens remain unchanged.
- V2 rejects invalid page values consistently.
- Default and maximum page sizes are exact.
- Above-maximum page sizes fail with a documented structured error rather than silently changing cost.
- Empty and past-the-end responses follow the v2 rules.
- Filters affect
totalCountbefore pagination. - Every sort includes and tests its unique tie-breaker.
- Composite rows preserve a documented composite identity.
Database tests
- The data query contains a database-side page boundary where applicable.
- Enrichment queries receive at most the IDs from the page.
- Query count is constant as page size and candidate count grow.
- Appropriate indexes support filter, sort, tie-breaker, and count predicates.
EXPLAIN (ANALYZE, BUFFERS)is captured for the default and maximum page on a production-sized snapshot.
UI tests
- Page state lives in the URL and survives refresh/back/forward navigation.
- Changing a filter resets the page to one.
- Loading a new page keeps the previous board visible only where that is the screen's established behavior.
- Empty, first, middle, last, and past-the-end pages render correctly.
- Browser network tests assert the requested
pageSizeand a response-byte ceiling for each screen. - Summary screens do not fetch their underlying detail collection.
Load and failure tests
- Test default and maximum pages under realistic concurrency.
- Verify
429andRetry-Afterat the application and edge layers. - Abort a client request and verify database work receives cancellation.
- Exhaust the expensive-route semaphore and verify bounded rejection rather than pool starvation.
- Make the database unavailable and verify readiness fails while liveness still reports the process correctly.
Definition of done
Pagination is complete when all of the following are true:
- Every legacy contract test passes without updating its expected response.
- Every growing v2 collection uses
pageandpageSizewith the common response envelope. - Every unpaged v2 collection has an enforced, documented cardinality ceiling.
- Every paged query has deterministic ordering with a unique final tie-breaker.
- Page size bounds the response and dominant per-row enrichment work.
- Every experimental table requests only the rows it renders.
- Every experimental summary uses an aggregate DTO rather than downloading detail rows.
- Response-byte and database-work budgets are measured in continuous integration for the priority endpoints.
- V2 rate limiting and resource limits have documented, tested behavior.
- V1 remains mounted and observably unchanged.