Problem
Several tables in the dashboard render an unbounded number of rows in a single page load with no pagination. This causes slow renders, large DOM sizes, and in some cases silently truncates data the user cannot reach.
Affected tables
1. Website events table — /events/:event_id (all four event types)
View: app/views/shared/_events_table.html.erb
Data source: GaResponseBuilder#response — a single GA4 page with limit: 10_000
The template renders all rows from the GA4 response directly:
<% @events.rows.each do |row| %>
The template already shows a "Showing X–Y of Z items" header using total_results, start_index, and items_per_page from the GA4 response — but there are no next/previous controls. When total_results exceeds 10,000 (confirmed for at least one hub), items beyond the first 10,000 are inaccessible in the UI. The CSV export uses multi_page_response and is unaffected.
Fix: GaResponseBuilder already accepts a start_index= setter that maps to the GA4 offset parameter. Adding a page query param to the controller, passing it to the builder, and rendering prev/next links in the template is sufficient.
2. Search terms table — /search_terms/website, /search_terms/api
View: app/views/shared/_search_terms_table.html.erb
Data source: GaResponseBuilder#response — same single GA4 page, limit: 10_000
Same pattern as the events table: "Showing X–Y of Z terms" header is already present, no navigation controls. When total unique search terms exceed 10,000 for a hub/date range, later terms are unreachable.
Fix: Same approach as events — page param → start_index offset in the builder.
3. Contributors index — /hubs/:hub_id/contributors
View: app/views/contributors/index.html.erb
Data source: Hub.contributors_item_count → HubStats S3 file — all contributors for the hub, no cap
<% @contributors_item_count.each do |c| %>
Currently renders all contributors in one table. Digital Maryland has 2,652; no other hub is close today, but the count will grow over time.
Fix: Slice @contributors_item_count in the controller by page offset. Since the data is already loaded in memory from the S3 cache, this is pure Ruby slicing — no additional data source changes needed. Add a total count and prev/next links.
4. Contributor comparison table — hub show page (async section)
View: app/views/shared/_contributor_comparison.html.erb
Data source: ContributorComparison#totals → contributors_item_count — same S3-sourced array, all contributors
<% @contributor_comparison.totals.each do |contributor, totals| %>
Rendered as an async section via render_async_section. The comparison table also loads GA4 data per contributor via a follow-up fetch — rendering all 2,652 Digital Maryland contributors triggers 2,652 individual GA4 data-cell fetches in the browser.
Fix: Same server-side slicing as #3. The follow-up GA4 fetch should only request data for the visible page of contributors.
Suggested page size
50 rows per page for all four tables is a reasonable starting point and consistent with common dashboard conventions. The page size could be made configurable later.
Notes
- Tables 1 and 2 can share a single implementation since both use
GaResponseBuilder with identical pagination support.
- Tables 3 and 4 share the same data source (
contributors_item_count) and can share a page parameter, but the comparison table's async GA4 fetch needs to be scoped to the current page's contributor slice.
- The existing CSV download links are unaffected — they use
multi_page_response (events/search terms) or the full unsliced array (contributors) and should continue to export all data.
Problem
Several tables in the dashboard render an unbounded number of rows in a single page load with no pagination. This causes slow renders, large DOM sizes, and in some cases silently truncates data the user cannot reach.
Affected tables
1. Website events table —
/events/:event_id(all four event types)View:
app/views/shared/_events_table.html.erbData source:
GaResponseBuilder#response— a single GA4 page withlimit: 10_000The template renders all rows from the GA4 response directly:
The template already shows a "Showing X–Y of Z items" header using
total_results,start_index, anditems_per_pagefrom the GA4 response — but there are no next/previous controls. Whentotal_resultsexceeds 10,000 (confirmed for at least one hub), items beyond the first 10,000 are inaccessible in the UI. The CSV export usesmulti_page_responseand is unaffected.Fix:
GaResponseBuilderalready accepts astart_index=setter that maps to the GA4offsetparameter. Adding apagequery param to the controller, passing it to the builder, and rendering prev/next links in the template is sufficient.2. Search terms table —
/search_terms/website,/search_terms/apiView:
app/views/shared/_search_terms_table.html.erbData source:
GaResponseBuilder#response— same single GA4 page,limit: 10_000Same pattern as the events table: "Showing X–Y of Z terms" header is already present, no navigation controls. When total unique search terms exceed 10,000 for a hub/date range, later terms are unreachable.
Fix: Same approach as events —
pageparam →start_indexoffset in the builder.3. Contributors index —
/hubs/:hub_id/contributorsView:
app/views/contributors/index.html.erbData source:
Hub.contributors_item_count→HubStatsS3 file — all contributors for the hub, no capCurrently renders all contributors in one table. Digital Maryland has 2,652; no other hub is close today, but the count will grow over time.
Fix: Slice
@contributors_item_countin the controller by page offset. Since the data is already loaded in memory from the S3 cache, this is pure Ruby slicing — no additional data source changes needed. Add a total count and prev/next links.4. Contributor comparison table — hub show page (async section)
View:
app/views/shared/_contributor_comparison.html.erbData source:
ContributorComparison#totals→contributors_item_count— same S3-sourced array, all contributorsRendered as an async section via
render_async_section. The comparison table also loads GA4 data per contributor via a follow-up fetch — rendering all 2,652 Digital Maryland contributors triggers 2,652 individual GA4 data-cell fetches in the browser.Fix: Same server-side slicing as #3. The follow-up GA4 fetch should only request data for the visible page of contributors.
Suggested page size
50 rows per page for all four tables is a reasonable starting point and consistent with common dashboard conventions. The page size could be made configurable later.
Notes
GaResponseBuilderwith identical pagination support.contributors_item_count) and can share a page parameter, but the comparison table's async GA4 fetch needs to be scoped to the current page's contributor slice.multi_page_response(events/search terms) or the full unsliced array (contributors) and should continue to export all data.