Skip to content

Community API Pagination & Query Optimization — PRD

Product: Objectuve (codename: Enkidu) Feature: Pagination, query limits, and N+1 prevention for community endpoints Status: Shipped — April 2026 Date: April 2026 Priority: High — impacts performance at scale


✓ SHIPPED — Delivered April 2026. All acceptance criteria met and live in production.

1. Problem Statement

Community API queries have no pagination or result limits, except for the feed endpoint. As communities grow, these unbounded queries will degrade performance and eventually crash the app.

Current state of 5 community queries:

QueryPagination?Unbounded DataRisk
COMMUNITIES_QUERY (list page)NoneReturns ALL communities, each with ALL members nestedCritical
COMMUNITY_QUERY (detail page)NoneReturns ALL members, ALL goals, ALL feedItems in one responseCritical
communityMembers (members tab)NoneReturns ALL members with N+1 queries on goalsCompleted and postsCountCritical
communityGoals (goals tab)NoneReturns ALL goals, each with ALL events nestedCritical
communityFeed (feed tab)Yes (limit/offset)None — correctly paginatedOK

Failure scenario: A community with 10,000 members triggers:

  • 500KB+ payload on the list page (members nested in every community)
  • 800KB+ payload on the detail page
  • 20,000+ database queries from N+1 in member resolvers

This doesn't affect us today with small communities, but will break as soon as any community grows past a few hundred members.


2. Goals

  1. Add pagination (limit/offset) to all unbounded community queries
  2. Eliminate N+1 queries in CommunityMemberType resolvers
  3. Remove nested member arrays from the communities list query (not needed for the list page)
  4. Ensure no single GraphQL response exceeds ~100KB for normal usage

3. Proposed Changes

3a. COMMUNITIES_QUERY — Remove nested members, add memberCount

The list page only needs to know if the user is a member and how many members exist. It doesn't need the full member list for every community.

Backend changes (community_type.rb):

  • Add field :member_count, Integer, null: false — returns object.members.count
  • Add field :is_member, Boolean, null: false — checks object.members.exists?(user_id: context[:current_user]&.id)

Frontend changes (social.js + Communities.vue):

  • Replace members { id userPublicId } with memberCount isMember
  • Replace c.members?.some(...) membership check with c.isMember

This eliminates the most critical payload issue — no member arrays in the list response.

3b. COMMUNITY_QUERY — Remove nested collections from detail query

The detail page loads members, goals, and feed through separate dedicated queries (via the tab system). The initial detail query doesn't need to include them.

Frontend changes:

  • Remove goals { publicId }, feedItems { ... }, and members { ... } from COMMUNITY_QUERY
  • The tab components already use COMMUNITY_MEMBERS_QUERY, COMMUNITY_GOALS_QUERY, and COMMUNITY_FEED_QUERY independently

3c. communityMembers — Add pagination

Backend changes (query_type.rb):

ruby
field :community_members, [Types::CommunityMemberType], null: true do
  argument :community_id, ID, required: true
  argument :limit, Integer, required: false, default_value: 50
  argument :offset, Integer, required: false, default_value: 0
end

def community_members(community_id:, limit: 50, offset: 0)
  community = Community.public_find(community_id)
  return [] unless community
  community.members.includes(:user).limit(limit).offset(offset)
end

Frontend changes:

  • Add limit and offset variables to COMMUNITY_MEMBERS_QUERY
  • Implement "Load More" or infinite scroll in the members tab

3d. communityGoals — Add pagination, remove nested events

Backend changes (query_type.rb):

ruby
field :community_goals, [Types::GoalType], null: true do
  argument :community_id, ID, required: true
  argument :limit, Integer, required: false, default_value: 20
  argument :offset, Integer, required: false, default_value: 0
end

def community_goals(community_id:, limit: 20, offset: 0)
  community = Community.public_find(community_id)
  return [] unless community
  community.goals.limit(limit).offset(offset)
end

Frontend changes:

  • Add limit/offset to COMMUNITY_GOALS_QUERY
  • Remove allEvents { ... } from the query — event counts should be a scalar field on the goal, not a nested array

3e. Fix N+1 queries in CommunityMemberType

Current problem (community_member_type.rb):

ruby
def goals_completed
  object.user.goals.where(completed: true).count  # N+1
end

def posts_count
  object.user.community_posts.where(community_id: object.community_id).count  # N+1
end

Fix options:

  1. Counter caches — add goals_completed_count and posts_count columns to community_members, maintained by callbacks
  2. Batch loading — use GraphQL batch loaders (graphql-batch gem) to batch the queries
  3. Eager loading — precompute in the resolver with a single GROUP BY query

Recommendation: Option 3 (eager loading) for MVP, option 1 (counter caches) for long-term:

ruby
def community_members(community_id:, limit: 50, offset: 0)
  community = Community.public_find(community_id)
  return [] unless community
  community.members
    .includes(:user)
    .left_joins(user: :goals)
    .select('community_members.*, COUNT(DISTINCT goals.id) FILTER (WHERE goals.completed = true) AS preloaded_goals_completed')
    .group('community_members.id')
    .limit(limit)
    .offset(offset)
end

4. Implementation Priority

Phase 1: Quick wins (high impact, low effort)

  1. Add memberCount and isMember fields to CommunityType
  2. Remove nested members, goals, feedItems from COMMUNITY_QUERY
  3. Remove nested members from COMMUNITIES_QUERY, use memberCount + isMember

Phase 2: Pagination

  1. Add limit/offset to communityMembers query
  2. Add limit/offset to communityGoals query
  3. Remove allEvents from goal query (use scalar eventCount instead)
  4. Frontend: wire up "Load More" for members and goals tabs

Phase 3: Query optimization

  1. Fix N+1 in CommunityMemberType with eager loading
  2. Add database indexes if missing (community_members.user_id, community_posts.community_id)
  3. Consider counter caches for frequently accessed counts

5. Files to Change

Backend

FileChange
rails_api/app/graphql/types/community_type.rbAdd memberCount, isMember fields
rails_api/app/graphql/types/query_type.rbAdd limit/offset args to communityMembers, communityGoals
rails_api/app/graphql/types/community_member_type.rbFix N+1 in goals_completed, posts_count

Frontend

FileChange
ionic_frontend/src/constants/graphql/social.jsUpdate queries: remove nested members from list, add pagination vars
ionic_frontend/src/views/Communities.vueUse isMember instead of members.some()
ionic_frontend/src/views/Community.vueRemove nested collections from detail query, rely on tab queries
ionic_frontend/src/components/communities/CommunityMembers.vueAdd "Load More" pagination
ionic_frontend/src/components/communities/CommunityGoals.vueAdd "Load More" pagination

6. Success Metrics

MetricCurrentTarget
COMMUNITIES_QUERY payload sizeUnbounded (grows with members)< 50KB for 100 communities
COMMUNITY_QUERY payload sizeUnbounded< 10KB (scalar fields only)
communityMembers response timeO(N) queries per memberO(1) query total
Max single response payloadNo limit< 100KB

7. Out of Scope

  • Cursor-based pagination (limit/offset is sufficient for our scale)
  • Real-time member count updates via WebSocket
  • Community search (full-text backend search)
  • Caching layer (Redis-backed query caching)

Last updated: 2026-07-16

Loading…