Skip to main content
RapidDev - Software Development Agency
outsystems-tutorial

10 Ways to Speed Up Your OutSystems Aggregates

The #1 performance fix for OutSystems Aggregates is setting Max Records explicitly — without it, every Aggregate fetches ALL rows. The #2 fix is eliminating N+1 patterns: never put an Aggregate inside a For Each loop. For complex queries on large datasets, check execution plans in Service Center (O11) or ODC Portal, then add indexes on filtered and sorted columns.

What you'll learn

  • Identify and eliminate N+1 query patterns that cause exponential database load
  • Set Max Records and Start Index correctly for paginated screen queries
  • Create database indexes on filtered and sorted entity attributes
  • Read SQL execution plans in Service Center (O11) and ODC Portal to diagnose slow queries
  • Apply 10 concrete optimization patterns for Aggregates on tables with 10,000+ rows
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced14 min read40-60 minOutSystems 11 and ODCMarch 2026RapidDev Engineering Team
TL;DR

The #1 performance fix for OutSystems Aggregates is setting Max Records explicitly — without it, every Aggregate fetches ALL rows. The #2 fix is eliminating N+1 patterns: never put an Aggregate inside a For Each loop. For complex queries on large datasets, check execution plans in Service Center (O11) or ODC Portal, then add indexes on filtered and sorted columns.

When Aggregates Become a Performance Bottleneck

OutSystems Aggregates start performing poorly at scale for predictable reasons: missing Max Records limits cause full-table fetches, N+1 patterns in loops execute one query per row, and missing indexes cause full table scans on filtered columns. These are not OutSystems-specific problems — they are the same performance anti-patterns that exist in any ORM-based application.

This tutorial covers 10 concrete optimization techniques, ordered from highest-impact to lowest. It assumes you already know how to create Aggregates (see outsystems-aggregate-tutorial) and are now debugging slow queries in a deployed application.

Prerequisites

  • An OutSystems Reactive Web app deployed to a non-Personal environment (optimization requires real load testing)
  • Service Center access (O11) or ODC Portal access (ODC) for execution plan analysis
  • Familiarity with Aggregate basics, filters, and joins (see related tutorials)

Step-by-step guide

1

Tip 1: Always set Max Records — the most impactful fix

An Aggregate with no Max Records limit executes `SELECT * FROM Employee` with no row limit — on a table with 500,000 rows, this fetches all 500,000 rows on every screen load. Fix: In every Aggregate, click the Aggregate node (the top node in the Aggregate editor, showing the Aggregate name). In the Properties panel → Max Records, enter the maximum number of rows your screen will ever display. For list screens: Max Records = PageSize (typically 10-50) For dropdown sources: Max Records = 200-500 (max reasonable list) For full data export: Max Records = blank intentionally, but only in a background process, never on a screen TrueChange warning: 'Aggregate does not have a Max Records limit' — this is a warning, not an error, but should be treated as a required fix for production screens.

typescript
1/* Aggregate properties — set in Properties panel */
2Max Records: 20 /* for paginated list */
3Start Index: StartIndex /* Integer variable, default 0, for pagination */
4
5/* Pagination Client Action: go to next page */
6/* Assign: StartIndex = StartIndex + 20
7 Refresh Data: GetEmployees */
8
9/* Pagination Client Action: go to previous page */
10/* Assign: StartIndex = If(StartIndex - 20 < 0, 0, StartIndex - 20)
11 Refresh Data: GetEmployees */
12
13/* Count for 'Page X of Y' label */
14"Page " + IntegerToText((StartIndex / 20) + 1) + " of " + IntegerToText(Ceil(GetEmployees.Count / 20))

Expected result: Aggregate fetches exactly Max Records rows per page load instead of the entire table. Database query time drops proportionally.

2

Tip 2: Eliminate N+1 patterns — never aggregate inside a For Each

The N+1 query anti-pattern: for each employee row (N rows), executing one Aggregate to fetch the manager's name. N=1,000 employees = 1,001 queries. BAD pattern: For Each Employee in EmployeeList → GetManager (Aggregate: Employee where Id = CurrentEmployee.ManagerId) → Assign ManagerName = GetManager.List.Current.Employee.Name End For Each GOOD pattern: join Manager at the Aggregate level. In the main GetEmployees Aggregate, add the Employee entity a second time (self-join) as 'Manager'. Join Manager.Id = Employee.ManagerId using a With or Without join. Access Manager data as `GetEmployees.List.Current.Manager.Name`. This replaces N+1 queries with 1 single query containing a self-join.

typescript
1/* N+1 BAD — DO NOT DO THIS */
2/* For Each loop over 1000 employees */
3For Each (GetAllEmployees.List)
4 GetManagerSQL.DepartmentId = CurrentEmployee.DepartmentId /* 1 query per iteration */
5 /* Result: 1001 SQL queries for 1000 employees */
6End
7
8/* N+1 GOOD — single Aggregate with self-join */
9/* Aggregate: GetEmployeesWithManagers */
10/* Sources:
11 Entity 1: Employee (alias: Employee)
12 Join: With or Without
13 Entity 2: Employee (alias: Manager, joined on Employee.ManagerId = Manager.Id)
14*/
15/* Access: */
16GetEmployeesWithManagers.List.Current.Employee.FirstName
17GetEmployeesWithManagers.List.Current.Manager.FirstName /* manager name, one query */
18
19/* Also avoid N+1 with related entities via joins */
20/* Instead of fetching Department in a loop: */
21/* Add Department to the Employee Aggregate with a join once */

Expected result: Database query count drops from N+1 to 1 for the same screen load. Measured in Service Center logs or browser developer tools network tab.

3

Tip 3: Add database indexes on filtered and sorted columns

A filter like `Employee.DepartmentId = @DeptId` on a table with 100,000 rows does a full table scan without an index. OutSystems auto-creates an index for FK attributes (Entity Identifiers), but not for other filtered columns like IsActive, HireDate, or LastName. To add an index in O11: 1. Open Service Center → Factory → Modules → [your module] → Entities 2. Click the entity → Indexes tab → New Index 3. Name the index, select the attribute(s), check Unique if applicable → Create Index Common indexes to add: - IsActive (Boolean): for frequent `WHERE IsActive = True` filters - HireDate (Date): for date range filters - LastName (Text): for text search filters - StatusId: auto-created by FK, but verify it exists For compound queries filtering on multiple columns simultaneously (DepartmentId AND IsActive), create a composite index with both columns.

typescript
1/* Service Center: Entity index creation */
2/* Factory → Modules → YourModule → Entities → Employee → Indexes → New Index */
3
4/* Index 1: single-column on IsActive */
5Index Name: Employee_IsActive
6Attributes: [IsActive] ASC
7Unique: No
8
9/* Index 2: composite for common filter (Dept + Active) */
10Index Name: Employee_DeptActive
11Attributes: [DepartmentId] ASC, [IsActive] ASC
12Unique: No
13
14/* Index 3: for text search — limited value but helps starts-with */
15Index Name: Employee_LastName
16Attributes: [LastName] ASC
17Unique: No
18
19/* ODC: same process via ODC Portal → Application → Entity → Indexes */
20/* ODC uses Aurora PostgreSQL — index syntax is managed by the platform */
21
22/* Verifying index usage: check SQL execution plan in Service Center */
23/* Look for: Index Seek (good) vs Table Scan / Index Scan (review) */

Expected result: Database query execution plans show 'Index Seek' instead of 'Table Scan' for the indexed columns. Filter query time drops from seconds to milliseconds on large tables.

4

Tip 4: Only fetch columns your screen actually uses

OutSystems automatically fetches only columns that are referenced in your app code. However, this optimization can be defeated by using `GetEmployees.List.Current.Employee.*` (all attributes via a structure variable) instead of specific attribute references. To verify which columns are being fetched: in the Aggregate editor, look at the column list. Columns shown in a darker weight are being used; lighter columns are not fetched. Grayed-out columns are excluded from the generated SQL. To help OutSystems minimize the SELECT: - Only add List/Table widget bindings for columns you display - Avoid binding the entire Employee record to a local variable if you only need Name and Email - For a name-only dropdown, only reference Employee.Id and Employee.Name in bindings This is most impactful for entities with Binary Data attributes (file content) — ensure binary columns are NOT fetched unless you are displaying file content.

Expected result: The generated SQL SELECT contains only the columns referenced in your bindings. Use Service Center's query log to verify the SELECT column list matches your expectations.

5

Tip 5: Read the execution plan in Service Center (O11) and ODC Portal

Service Center is the O11 operations console. Access it at https://your-environment/ServiceCenter. To find slow queries: 1. Service Center → Monitoring → Database Logs (enable slow query logging first) 2. Or: Service Center → Monitoring → Request Logs → find a slow request → click to see its queries 3. Look for queries with high Duration. Click the query → 'Show Query Plan' Reading the plan: - Index Seek: optimal, using an index efficiently - Index Scan: reads all index entries — add a more specific filter - Table Scan / Clustered Index Scan: reading every row — missing index - Nested Loops with high estimated rows: possible N+1 pattern For ODC (Aurora PostgreSQL): 1. ODC Portal → Application → Logs → Database 2. Filter by Duration > 500ms to find slow queries 3. Copy the query and run EXPLAIN ANALYZE in psql or a PostgreSQL client TrueChange performance warnings (bottom pane): look for orange warnings about missing Max Records, large aggregates, or Binary Data in screens.

Expected result: You can identify the specific Aggregate causing slow load times, see its SQL, and read the execution plan to determine whether an index or query restructuring is needed.

6

Tips 6-10: Additional optimization patterns

Tip 6 — Move expensive Aggregates to Server Actions: Screen Aggregates run in the browser rendering cycle. Aggregates in Server Actions run on the application server before sending the response. For complex multi-join aggregates, wrapping them in a Server Action and caching the result in a Session Variable can reduce per-request database load. Tip 7 — Use 'Only on demand' Fetch for below-the-fold content: Set the Aggregate's Fetch property to 'Only on demand' for data that is not visible on initial page load (tab panels, accordions, pagination). Trigger the fetch via DataAction.Fetch when the user scrolls to or opens the section. Tip 8 — Apply most restrictive filters first: OutSystems passes filters to SQL in the order they appear in the Filters tab. Place filters on indexed columns (DepartmentId, IsActive) before text-search filters (Index()). The database optimizer may reorder them, but explicit ordering helps with readability and some optimizer hints. Tip 9 — Use Aggregates with Count() instead of fetching a full list just to count: If you only need `GetEmployees.Count` (not the list), create a separate Aggregate with Max Records = 1 and the same filters — Count still returns the full count, but you don't transfer row data. Or use the Aggregate's Count property directly without rendering the List. Tip 10 — For reports on 1M+ rows, switch to Advanced SQL with streaming: Very large data exports should use Advanced SQL with server-side streaming rather than fetching all rows into memory. Consider delegating heavy analytical queries to a dedicated reporting solution rather than running them against the OLTP database.

typescript
1/* Tip 7: Fetch on demand pattern */
2/* Aggregate property: Fetch = Only on demand */
3/* Trigger in Client Action (e.g., OnTabSelected): */
4/* GetDepartmentDetails.Fetch -- triggers the deferred aggregate */
5
6/* Tip 9: Count-only Aggregate for dashboard numbers */
7/* Aggregate: GetEmployeeCount */
8/* Sources: Employee */
9/* Filters: Employee.IsActive = True */
10/* Max Records: 1 -- limits row transfer, Count still accurate */
11/* Use: GetEmployeeCount.Count on screen, don't bind .List */
12
13/* Tip 6: Cache in Session Variable (use sparingly) */
14/* If the same list is needed on multiple screens and rarely changes: */
15/* Server Action: GetCachedProductList */
16/* Check Session.ProductListLoaded Boolean */
17/* If False: run Aggregate → Store in Session.ProductList → Set Boolean True */
18/* Return Session.ProductList */
19/* Invalidate by setting Boolean to False when products change */

Expected result: Each optimization tip measurably reduces query count, data transfer, or CPU time. Use Service Center request logs before and after each optimization to measure improvement.

Complete working example

AggregatePerformance_Checklist.outsystems
1/* =====================================================
2 OutSystems Aggregate Performance Checklist
3 Apply in order of impact fix #1 and #2 first
4 ===================================================== */
5
6/* CHECKLIST — paste into your team's code review template */
7
8/* 1. Max Records set? */
9GetEmployees.MaxRecords /* must be a number or variable, never blank on screen Aggregates */
10
11/* 2. No Aggregate inside For Each? */
12/* Search your module for Aggregate nodes inside For Each loops */
13/* Replace with Aggregate joins */
14
15/* 3. Index on filtered columns? */
16/* For each Aggregate filter condition, verify:
17 Employee.DepartmentId = x DepartmentId has FK index (auto-created)
18 Employee.IsActive = True IsActive needs manual index in Service Center
19 Employee.HireDate >= x HireDate needs manual index
20 Index(Employee.Name, x) full text scan, no index help */
21
22/* 4. Only used columns in SELECT? */
23/* Verify in Aggregate editor: unused columns appear grayed out */
24/* No Binary Data columns fetched in list queries */
25
26/* 5. TrueChange warnings reviewed? */
27/* TrueChange tab → filter by Type = Warning → check all Aggregate warnings */
28
29/* 6. Fetch = Only on demand for below-fold content? */
30/* Aggregates loading data for hidden tabs, collapsed sections, or modal popups */
31/* should use Fetch = Only on demand */
32
33/* 7. Execution plan reviewed for Table Scans? */
34/* Service Center → Monitoring → Request Logs → slow request */
35/* → query details → Show Query Plan → no Table Scans on large tables */
36
37/* 8. Single Aggregate with joins instead of N+1 subloops? */
38/* Verify: no GetX aggregate called inside a For Each that iterates a list from GetY */
39
40/* 9. Correct join types — no unnecessary Full Outer Joins? */
41/* Only With (INNER JOIN) when possible — narrowest join type */
42/* With or Without (LEFT JOIN) only when NULLs must be preserved */
43/* With (FULL OUTER) — rare, avoid unless genuinely needed */
44
45/* 10. Large exports in background processes, not screen actions? */
46/* Timer or background Server Action for > 10,000 row exports */
47/* Never fetch entire table on screen load for export functionality */

Common mistakes

Why it's a problem: Placing an Aggregate inside a For Each loop to fetch related data row by row

How to avoid: This is the N+1 anti-pattern and the most critical performance mistake in OutSystems. Move the related data fetch into the parent Aggregate using a join. If the join is too complex, pre-fetch all needed records before the loop and use ListFilter() or a local structure to look up values by ID without additional queries.

Why it's a problem: Using GetEmployees.Count to display record counts while also fetching GetEmployees.List — causing the query to run twice

How to avoid: A single Aggregate provides both .List and .Count from one query execution. You do not need two separate Aggregates for the list and the count. Use GetEmployees.List for the list widget AND GetEmployees.Count for the count label — both come from the same single database query.

Why it's a problem: Adding filters after the Aggregate is already hitting performance limits, then wondering why the index does not help

How to avoid: Indexes only help when the filter is on an INDEXED column. Adding `Employee.IsActive = True` as a filter does not use an index unless you have created an index on IsActive in Service Center. Create the index first, then verify with the execution plan that it is being used (Index Seek, not Table Scan).

Why it's a problem: Setting Max Records to a large number (like 99999) instead of a meaningful limit

How to avoid: Setting Max Records to 99999 defeats the purpose of the limit — you are effectively fetching all rows for large tables. Set Max Records to the actual maximum you will display: 20 for a paginated list, 100 for a dropdown, etc. For genuinely unlimited exports, use a background process, not a screen Aggregate.

Best practices

  • Treat TrueChange performance warnings (orange) as required fixes before production, not cosmetic suggestions — they represent patterns that cause measurable degradation at scale.
  • Establish a Max Records rule in your team's coding standards: every Screen Aggregate visible to end-users must have Max Records set. Make this a pull request checklist item.
  • Profile Aggregates in Development with realistic data volumes — a table with 100 test records performs fine without indexes, but 100,000 rows makes the problem obvious. Use a data generator or load sample data early.
  • Use Service Center's slow query log regularly, not just when users complain — set a threshold of 200ms and review weekly. Performance issues caught early are much cheaper to fix.
  • For OutSystems ODC, indexes created in ODC Portal are managed separately from application deployments — applying an index to production requires doing it through ODC Portal, not through a code publish.
  • Avoid using 'Fetch = At Start' for aggregates that require parameters not available until after some user interaction — use 'Only on demand' and trigger manually to prevent unnecessary queries on screen load.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have an OutSystems O11 Reactive Web screen that loads slowly. The screen has an Aggregate GetEmployees with no Max Records, joined to Department and EmploymentStatus. The screen loads 5,000 rows. Walk me through the 5 most impactful performance fixes in order: Max Records, N+1 detection, index creation in Service Center, column fetch optimization, and reading the execution plan. Include the exact Service Studio and Service Center UI paths.

OutSystems Prompt

My OutSystems module has a For Each loop over a 500-item employee list, and inside the loop I call a GetDepartmentDetails Aggregate for each employee. How do I refactor this N+1 pattern into a single Aggregate with a join? Show me the Aggregate Sources tab configuration and how to access the joined data in the action flow.

Frequently asked questions

How many rows is 'too many' for an OutSystems Aggregate before performance degrades?

There is no universal threshold — it depends on whether indexes are in place, how many joins are involved, and what hardware the database runs on. As a practical guideline: without indexes, expect noticeable slowdown around 10,000-50,000 rows. With proper indexes, Aggregates on 1-10 million rows can still return in under 100ms. The most important factor is always Max Records and proper indexes on filtered columns.

Does OutSystems cache Aggregate results automatically?

No. OutSystems does not cache Aggregate results by default. Every Aggregate call executes a database query. If you need caching, implement it manually using Session Variables (user-level cache) or Site Properties (module-level shared values for static lookup data). Cache invalidation is your responsibility — set a TTL variable and re-fetch when expired.

What is TrueChange's 'Aggregate may have performance issues' warning about?

TrueChange shows this orange warning when an Aggregate has no Max Records set, or when it fetches from a very large entity without any filter conditions. It is a warning (not an error), so publishing is not blocked. However, treat it as a required fix: set Max Records and add appropriate filters. Left unfixed, this warning consistently produces slow screen load times in production.

How do I monitor slow Aggregates in production without exposing Service Center to all developers?

In O11, Service Center has role-based access — grant the 'Monitor & Report' role to team members who need performance monitoring without full administrative access. You can also configure OutSystems to send performance metrics to external monitoring tools (Elastic, Datadog) via the built-in monitoring integration. For ODC, use the ODC Portal Monitoring section with team-level access controls.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.