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:
| Query | Pagination? | Unbounded Data | Risk |
|---|---|---|---|
COMMUNITIES_QUERY (list page) | None | Returns ALL communities, each with ALL members nested | Critical |
COMMUNITY_QUERY (detail page) | None | Returns ALL members, ALL goals, ALL feedItems in one response | Critical |
communityMembers (members tab) | None | Returns ALL members with N+1 queries on goalsCompleted and postsCount | Critical |
communityGoals (goals tab) | None | Returns ALL goals, each with ALL events nested | Critical |
communityFeed (feed tab) | Yes (limit/offset) | None — correctly paginated | OK |
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
- Add pagination (limit/offset) to all unbounded community queries
- Eliminate N+1 queries in CommunityMemberType resolvers
- Remove nested member arrays from the communities list query (not needed for the list page)
- 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— returnsobject.members.count - Add
field :is_member, Boolean, null: false— checksobject.members.exists?(user_id: context[:current_user]&.id)
Frontend changes (social.js + Communities.vue):
- Replace
members { id userPublicId }withmemberCount isMember - Replace
c.members?.some(...)membership check withc.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 { ... }, andmembers { ... }fromCOMMUNITY_QUERY - The tab components already use
COMMUNITY_MEMBERS_QUERY,COMMUNITY_GOALS_QUERY, andCOMMUNITY_FEED_QUERYindependently
3c. communityMembers — Add pagination
Backend changes (query_type.rb):
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)
endFrontend changes:
- Add
limitandoffsetvariables toCOMMUNITY_MEMBERS_QUERY - Implement "Load More" or infinite scroll in the members tab
3d. communityGoals — Add pagination, remove nested events
Backend changes (query_type.rb):
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)
endFrontend changes:
- Add
limit/offsettoCOMMUNITY_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):
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
endFix options:
- Counter caches — add
goals_completed_countandposts_countcolumns tocommunity_members, maintained by callbacks - Batch loading — use GraphQL batch loaders (graphql-batch gem) to batch the queries
- 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:
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)
end4. Implementation Priority
Phase 1: Quick wins (high impact, low effort)
- Add
memberCountandisMemberfields to CommunityType - Remove nested
members,goals,feedItemsfromCOMMUNITY_QUERY - Remove nested
membersfromCOMMUNITIES_QUERY, usememberCount+isMember
Phase 2: Pagination
- Add limit/offset to
communityMembersquery - Add limit/offset to
communityGoalsquery - Remove
allEventsfrom goal query (use scalareventCountinstead) - Frontend: wire up "Load More" for members and goals tabs
Phase 3: Query optimization
- Fix N+1 in CommunityMemberType with eager loading
- Add database indexes if missing (
community_members.user_id,community_posts.community_id) - Consider counter caches for frequently accessed counts
5. Files to Change
Backend
| File | Change |
|---|---|
rails_api/app/graphql/types/community_type.rb | Add memberCount, isMember fields |
rails_api/app/graphql/types/query_type.rb | Add limit/offset args to communityMembers, communityGoals |
rails_api/app/graphql/types/community_member_type.rb | Fix N+1 in goals_completed, posts_count |
Frontend
| File | Change |
|---|---|
ionic_frontend/src/constants/graphql/social.js | Update queries: remove nested members from list, add pagination vars |
ionic_frontend/src/views/Communities.vue | Use isMember instead of members.some() |
ionic_frontend/src/views/Community.vue | Remove nested collections from detail query, rely on tab queries |
ionic_frontend/src/components/communities/CommunityMembers.vue | Add "Load More" pagination |
ionic_frontend/src/components/communities/CommunityGoals.vue | Add "Load More" pagination |
6. Success Metrics
| Metric | Current | Target |
|---|---|---|
| COMMUNITIES_QUERY payload size | Unbounded (grows with members) | < 50KB for 100 communities |
| COMMUNITY_QUERY payload size | Unbounded | < 10KB (scalar fields only) |
| communityMembers response time | O(N) queries per member | O(1) query total |
| Max single response payload | No 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)
Related Documentation
- Communities Deep Dive — feature doc for the shipped implementation
Last updated: 2026-07-16