Case studies / Open source

Reducing role queries across Forem’s role-heavy paths

An open source Rails performance optimization that made role checks association-aware across Forem’s admin member list, CSV export, async user payload, and reCAPTCHA service.

At a glance

ProblemThe same users’ roles were queried repeatedly while each path evaluated several authorization questions
ScopeForem’s shared authorization policy, admin member list and CSV export, async user payload, and reCAPTCHA exemptions
ResultRole queries fell by 75.0%–99.3% across four measured paths, with lower SQL time, allocations, and total duration
DeliveryRegression-tested open source optimization merged upstream

Forem role-query optimization results across four paths: role queries fell from 568 to 4 for the admin member list, 139 to 3 for its CSV export, 13 to 3 for the async user payload, and 4 to 1 for the reCAPTCHA service.

The problem

Forem uses Rolify to answer authorization questions such as whether a user is an admin, trusted user, tag moderator, or subforem moderator. Several request paths asked multiple role-based questions about the same users, and each check could issue another COUNT or EXISTS query even when the user’s complete role collection could have been loaded once.

The pattern appeared in four measured paths. Rendering the admin member manager triggered 568 role queries and 683 SQL events. Exporting members to CSV produced 139 role queries among 152 SQL events. The authenticated async payload issued 13 role queries while assembling one user’s client-side data, and the reCAPTCHA exemption service issued four while deciding whether one user should bypass a challenge.

Measured result

I benchmarked each path with five measured executions after three warmups. The changes and their results are described together below.

Shared authorization layer

The common fix sits in Authorizer, which backs role-derived methods such as trusted?, any_admin?, and the moderator checks. Its private helpers previously called Rolify’s database-backed has_role? or has_any_role?, so loading user.roles elsewhere did not prevent further queries.

I changed those helpers to inspect Active Record’s association state. When the complete roles association is loaded, they use Rolify’s has_cached_role? against that in-memory collection; otherwise, they retain the original database-backed behavior. I also routed global tag- and subforem-moderator checks through these helpers with Rolify’s :any resource selector, while resource-specific checks continue to pass the resource itself.

This gave each optimized path one reusable, association-aware role-checking strategy without changing the behavior of callers that do not preload roles.

Admin member list: GET /admin/member_manager/users

The member list renders up to 50 users per page and checks several roles while deriving each user’s status. I added preload(:roles) to the paginated relation so Active Record fetches the page’s roles as a collection and Authorizer reuses them. This removed the per-user N+1 queries without changing pagination, filtering, or rendered controls.

MetricBefore meanAfter meanMean changeBefore medianAfter median
Request duration (ms)1,409.14893.24−36.6%1,371.71889.69
SQL duration (ms)284.2958.47−79.4%283.6258.22
Allocated objects965,895751,768−22.2%965,916751,768
SQL events683119−82.6%683119
Duplicate query fingerprints66196−85.5%66196
Role queries5684−99.3%5684
Role snapshot queries02n/a02
Role COUNT/EXISTS queries110.0%11

Admin CSV export: GET /admin/member_manager/users/export.csv

The export calls the same user_status logic for every CSV row and also reads each user’s organizations. I changed its relation to preload both :organizations and :roles, allowing organization names and role-backed statuses to come from batched association data while preserving the CSV columns and status precedence.

MetricBefore meanAfter meanMean changeBefore medianAfter median
Request duration (ms)229.24124.98−45.5%220.05129.37
SQL duration (ms)64.6610.24−84.2%62.0910.50
Allocated objects106,72850,653−52.5%106,72750,654
SQL events15216−89.5%15216
Duplicate query fingerprints1392−98.6%1392
Role queries1393−97.8%1393
Role snapshot queries02n/a02
Role COUNT/EXISTS queries110.0%11

Async user data: GET /async_info/base_data

This payload calculates several permission-derived values for one user. I made AsyncInfo call @user.roles.load once during initialization, before building the response, so direct and policy-mediated role checks share the loaded association.

MetricBefore meanAfter meanMean changeBefore medianAfter median
Request duration (ms)119.53116.66−2.4%119.69102.60
SQL duration (ms)18.4614.17−23.2%18.8113.92
Allocated objects30,58027,485−10.1%30,57527,484
SQL events3323−30.3%3323
Duplicate query fingerprints102−80.0%102
Role queries133−76.9%133
Role snapshot queries110.0%11
Role COUNT/EXISTS queries80−100.0%80

reCAPTCHA decision: ReCaptcha::CheckEnabled.call(user)

For signed-in users, the service evaluates several role-backed exemptions and restrictions. I loaded the user’s roles once after the configuration and anonymous-user early returns, allowing the remaining checks to share one snapshot through Authorizer while retaining their existing order and outcome.

MetricBefore meanAfter meanMean changeBefore medianAfter median
Service duration (ms)10.223.31−67.7%9.592.92
SQL duration (ms)4.761.41−70.4%4.531.25
Allocated objects2,6701,176−56.0%2,5671,072
SQL events52−57.7%52
Duplicate query fingerprints20−100.0%20
Role queries41−75.0%41
Role snapshot queries01n/a01
Role COUNT/EXISTS queries40−100.0%40

Across the four paths, SQL duration improved by 23.2%–84.2%, allocations fell by 10.1%–56.0%, and duplicate query fingerprints fell by 80.0%–100%. Timings depend on the surrounding workload, but the query counts show the deterministic improvement: repeated per-user and per-check lookups became bounded association loads.

Validation

Policy specs preload global and resource-scoped roles, verify the correct answers for admin, tag-moderator, and subforem-moderator checks, and assert that those checks issue no further role queries. Request coverage confirms that the admin member list does not query roles separately for each displayed user.

Additional specs count role queries while building async user data and evaluating reCAPTCHA exemptions. Manual verification covered the member list, CSV contents, async user data, and exemption behavior for admins, trusted users, and moderators. No user-facing behavior or UI changed.

Together, the changes established one reusable association-aware role-checking strategy rather than four isolated endpoint fixes. The optimization was reviewed, approved, and merged into Forem upstream.

View the merged Forem pull request →