StatsHub Docs
API

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:

  1. the UI requests too many rows;
  2. several endpoints return fields the screen never renders; and
  3. 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: limit and offset (source);
  • /api/value-bets: page and perPage (source);
  • /api/lineups/players: page and pageSize (source);
  • /api/referees/list: page and limit (source); and
  • /api/props/team-screener: limit and offset (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

EndpointCurrent requestDefault / maximumWork boundMain issue
/api/eventlimit, offset50 / 200SQL LIMIT/OFFSETNo total; timestamp sort lacks ID tie-breaker
/api/value-betspage, perPage25 / 100Group-key query is pagedPost-filters can shrink the page after totals are calculated
/api/props/screenerpage, pageSize1,000 / 1,000; minimum 10SQL LIMIT/OFFSET before enrichmentDefault is the maximum and much larger than the UI standard of 25
/api/props/team-screenerlimit, offset250 / noneSQL LIMIT/OFFSETNo maximum; total count is serialized as text
/api/referees/listpage, limit500 / noneSQL LIMIT/OFFSETNo maximum; unstable ties; broad aggregate row
/api/props/hunterpage, limit50 / noneAggregate query receives limit and offsetNo maximum; empty response shape can omit pagination
/api/team-shots-glm-predictions without positiveEv=truepage, perPage10 / 50SQL page before odds enrichmentPositive-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

EndpointCurrent requestDefault / maximumWork before the sliceRequired change in v2
/api/lineups/playerspage, pageSize100 / 500All upcoming candidates and their historyTwo-phase key query; page candidates before row projection when the filter permits
/api/props/outlierspage, limit1,000 / noneAll prices grouped and edge-sorted in GoCompute the market edge in SQL or a materialized read model, then page keys
/api/props/player-trendspage, pageSize25 / 200; 2,000-row work capAll capped candidates enriched and sortedRank eligible keys first, then enrich one page
/api/props/team-trendspage, pageSize50 / 200; 2,000-row work capAll capped candidates enriched and sortedRank eligible keys first, then enrich one page
/api/value-bets-v2page, perPage25 / 100Every qualifying group and its displayed edgePersist or compute the displayed sort key so SQL can select the page
/api/team-shots-glm-predictions?positiveEv=truepage, perPage10 / 50Every candidate is priced before in-memory filteringMove edge calculation/filtering into SQL, then page

Growing collections without pagination

Endpoint familyCurrent behaviorRiskv2 treatment
/api/player, /api/team, /api/managerGeneric listAll; manager currently lists playersUnbounded growing tables and a preserved legacy quirkPaginated directory DTOs; leave legacy routes untouched
/api/category, /api/country, /api/season, /api/venue, /api/models, /api/incident, /api/unique-tournamentGeneric full-table listSize grows silently; response shapes varyClassify each as growing or bounded reference data
/api/unique-tournament/{id}/{season}/player-statsEvery player and every stat aggregateLarge SQL projection; UI uses a small leaderboardSorted, projected player leaderboard endpoint
/api/data-visualizerEvery event in a day or date rangeThree correlated counts per event; month-sized responsePaged detail endpoint plus aggregate summary endpoint
/api/event/by-dateEvery visible fixture with full nested event, tournament, category, and team objectsBroad rows on the highest-traffic queryCompact fixture-day DTO; paginate drill-down where needed
/api/tournamentEvery visible competition, grouped after loadingUsually bounded, but not contractually cappedCompact bounded directory with an explicit hard ceiling
/api/props/super-sub-heroesNine leaderboards of up to 50 rows eachScreen uses one statisticRequire stat; page only that leaderboard
/api/searchHard limit 10 per entity typeBounded but cannot continueKeep 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:

  1. Existing routes remain mounted at the same paths.
  2. Existing query and body parameter names, defaults, parsing quirks, and out-of-range behavior do not change.
  3. Existing status codes, headers, cache behavior, field names, nesting, nulls, numeric strings, and array/object envelopes do not change.
  4. Existing ordering remains unchanged, including ordering that is not fully deterministic.
  5. Existing authentication and authorization behavior does not change.
  6. Existing generated OpenAPI operations stay present.
  7. No v1 route is silently redirected to a v2 handler.
  8. 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:

ParameterTypeRule
pageintegerOne-based; default 1; reject values below 1 with a structured 400
pageSizeintegerDefault 25; maximum 100; expensive endpoints may advertise a lower maximum
sortBystringEndpoint-specific allowlist; never interpolate caller input into SQL
sortOrderstringasc 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 data array; it is not clamped to the last page.
  • totalCount counts 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/OFFSET before 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):

  • LeaguePlayerLeaderboardRow
  • RefereeBoardRow
  • DataQualityEventRow
  • DataQualitySummary
  • SuperSubLeaderboardRow
  • FixtureDayRow

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

  1. 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.
  2. Record the legacy database query count and normalized table access with the existing contract harness. It already supports query-log assertions (source).
  3. Capture response bytes, returned rows, rows scanned, database duration, p50, p95, and p99 for the experimental UI's actual request URLs.
  4. 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 page and pageSize;
  • 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:

  1. 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.
  2. Referees. Add todayOnly as 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.
  3. 100 Club. Move from pageSize=1000 to 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.
  4. 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.
  5. Super Sub Heroes. Send the active stat to the server and return one paged leaderboard instead of nine top-50 lists.
  6. 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:

  1. write a first query that returns only ordered row keys plus the exact total;
  2. include the unique tie-breaker in that order;
  3. apply the page to the key query;
  4. enrich only those keys in bounded batch queries; and
  5. 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:

  1. edge limits absorb obvious abuse before it reaches Go; and
  2. 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_LIMITED code.

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:

  1. mark the old collection operation deprecated in generated OpenAPI;
  2. measure remaining v1 traffic by route and client identity;
  3. stop adding features to v1; and
  4. 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 totalCount before 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 pageSize and 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 429 and Retry-After at 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 page and pageSize with 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.

On this page