Lumos’ pages were grinding to a halt at scale. Through strategic optimizations, we unlocked a 7x speed boost across our most demanding queries.


Lumos gives Information and Security teams unprecedented visibility into their companies' application data through interactive table views and powerful filtering capabilities. But as our customers' datasets grew into the millions of rows, these features began pushing our infrastructure to its limits, impacting both performance and stability.
For instance, Lumos stores user data across several SQL tables. This architecture maintains proper normalization, but poses performance challenges for frequently-accessed summary views. Our Accounts page, for example, required six JOINs just to materialize the complete user data. Furthermore, these JOINs used unSARGable conditions, preventing MySQL from leveraging indexes to drive down query times. For our largest customers with 100s of thousands of accounts, this often meant page load times of > 30 seconds — well beyond acceptable performance thresholds!
This latency was hindering the IT teams' ability to manage their organizations. Day-to-day operations require quick access to user data: provisioning new employees, auditing access permissions, and analyzing application spend. Every 30-seconds IT administrators spent watching loading screens meant less time supporting their workforce.
It was clear to us that the patterns we used for loading our pages wouldn’t scale much further. We needed a step-change in performance—targeting p99 load times of 5s for critical data and 10s for pages overall. We launched an initiative to:
Previously, our approach was simple but inefficient: load everything at once. Table rows, counts, filters, and all supporting data would load on initial page render. This strategy made sense for smaller datasets—pay a small upfront cost for smoother subsequent interactions.
But the increasing scale of data we serve to our users meant we needed to start prioritizing critical information first, focusing on what administrators need most urgently.
We asked ourselves: What's the first thing an IT admin looks for when they land on a page? How could we deliver that instantly while ensuring the rest of their workflow remains fluid?
To answer that question, we created a three-tier hierarchy for page content, which informed our prioritization efforts.

This hierarchy wasn't just an organizational tool—it shaped our optimization strategy. By prioritizing essential data and optimizing core queries, we achieved up to 7x faster load times on our most demanding pages. Let's dive into how we improved each tier.

To achieve these page load improvements, we:
Our first major win came from rethinking how we store account data. As mentioned earlier, viewing a company's accounts required joining across six different tables—fine for startups, but was causing 30-second load times for enterprise customers with millions of records.
We explored several options to improve query performance, including migrating to Opensearch, a database engine designed for search and analytics. However, we ultimately chose a simpler path: denormalizing the accounts data within MySQL. This approach was attractive because it plugged into our existing infrastructure for monitoring, schema management, and development without introducing any new tech.
After implementing our denormalization strategy, we received immediate positive feedback from large customers:
"I've noticed a big improvement in my experience with Lumos! It used to take a long time to load data, but now, I can interact much more smoothly, and the response time is way better.”— IT Engineering Tech Manager, Large Banking Customer
Opensearch offered potential performance gains, but we would have had to rebuild our entire operational framework, from monitoring pipelines to testing suites. Instead, we opted to measure MySQL’s performance with denormalized data before committing to a more complex solution.
# Before: Complex query with 6 joins
SELECT accounts.*
FROM accounts
INNER JOIN domain_apps /* + 5 more joins */
WHERE /* complex conditions */
# After: Simple denormalized query
SELECT account_info.*
FROM accounts_denormalized
WHERE domain_id = ? AND status = 'ACTIVE'We coupled this change with some well-chosen indexes to bring our worst-case times for certain queries down even further, as we detail in the next section.
Denormalization alone wasn't enough, however, given the volume of our data. We added covering indexes to our denormalized data tables, designed to match the actual query patterns of the page. These speed up read-heavy workloads by storing the data required for a specific query within the index, eliminating the need to access the underlying table.
Covering indexes are structured to include both the filtering and sorting columns, following the pattern:
INDEX(equality_conditions, range_conditions, ordering_columns)## Large query
SELECT
id,
account_id,
org_id,
app_id,
status,
display_name,
email,
last_seen_at,
source_type,
...
FROM accounts_denormalized WHERE app_is_hidden IS FALSE
AND is_excluded IS FALSE AND org_id = 9000
AND app_id IN (10000001) AND status IN ('ACTIVE', 'UNKOWN')
AND is_deleted IS FALSE AND configuration != 'IGNORE'
ORDER BY
CASE status
WHEN 'ACTIVE' THEN 1
WHEN 'INACTIVE' THEN 2
WHEN 'UNKNOWN' THEN 3
END,
source_type != 'SOURCE_OF_TRUTH',
account_id
LIMIT 50;We created an index using all the columns above, putting the filtering conditions first, then the ordering columns.
CREATE INDEX idx_accounts_denormalized_covering ON accounts_denormalized (
-- filtering conditions first
org_id,
app_id,
status,
app_is_hidden,
is_excluded,
is_deleted,
configuration,
-- Then columns needed for ORDER BY
source_type,
external_id
)The mere addition of this index sped up the time taken for a representative SQL query from 2s to a mere 100ms! We then employed this strategy across our tables, with the biggest gains (up to 4x improvement in p99 times) seen in queries that previously required sorting large result sets. The covering indexes were effective for pagination queries that combine filtering and sorting.

While exact counts are crucial for our admin pages (like showing the number of users for a given application), computing them in real-time was becoming expensive as our customer data grew. We needed a way to pre-compute these counts without:
Our solution: an asset-based denormalization pipeline built on Dagster, a data orchestration platform for complex data pipelines. In this, we model both our source tables and denormalized count tables as software-defined assets, providing several benefits:
This infrastructure keeps our application logic clean and fast – the API layer simply reads from pre-computed tables while the pipeline handles all the complexity of maintaining accurate counts in the background. The approach worked exceptionally well for our slowest count queries, eliminating performance bottlenecks without compromising data accuracy or system stability.

An example of one such performance improvement for the counts on our Identities page:

Examining our page loads revealed that important but non-critical data—like custom columns and detailed permissions—was blocking our initial render. By restructuring our data fetching, we could show essential information immediately while loading enriched data in the background.
Here's how we implemented this on our Single App Accounts page:
We previously used this query we previously used to fetch all the table data on the page at once:
# Before: Everything at once
GetAppAccountData {
account {
id
username
email
permissions { .../* supporting information */ }
}
}Loading every permission for each account became a bottleneck for the initial page load, especially for larger customers. Breaking this into separate queries proved to be the key to faster load times. The next challenge, however, was presenting this staggered data elegantly to users.
For the supporting information shown above, we implemented a lazy loading pattern for table columns, loading the critical data first, then lazy loading permissions and custom columns. Here’s how it looks on a typical accounts page:

Even with optimized loading, some secondary data was still too heavy for sub-5s loading. Our solution? Progressive disclosure in table rows. Rather than loading all permissions for each account and showing the count in the cell, we return just the first permission with an "and more" indicator, significantly reducing payload size while preserving essential context.

Clicking this indicator then triggers a separate, smaller query to fetch the complete permissions list for just that account:

Here's how the final query structure looked:
# After: Staged loading
# Initial Load: Core Data
GetAppAccountData {
app {
account {
id
username
email
}
}
}
# Fast Follow: Permissions
GetAppAccountPermissions {
app {
id
account {
id
permissions(limit: 1) {}
}
}
}
# Load on request: Full Permissions List
GetAccountPermissions {
account {
id
permissions {}
}
}This worked well with real usage patterns: admins typically only need detailed permissions for specific accounts they're investigating. Combining these approaches reduced both our primary and secondary data load time for both the /apps and /app/* pages, as we see below.




By rethinking our data hierarchy and implementing targeted optimizations at each tier, we've transformed our pages' performance:
More importantly, this new data hierarchy has given us a framework for scaling our pages sustainably. IT admins not only get faster page loads—they get a more intuitive experience that prioritizes their most critical workflows.
Key optimizations by data hierarchy:
Critical Data (Tier 1)
Supporting Data (Tier 2 + 3)
While these improvements have dramatically improved performance of our core table views, we're just getting started with our new architecture. We're working on:
Have feedback about our web app performance? We'd love to hear from you.
Interested in tackling complex data problems at scale? We're building systems that help IT teams manage thousands of applications and millions of users. Check out our open roles.
Book a 1:1 demo with us and enable your IT and Security teams to achieve more.