Case Study
List Management Standards
A set of performance budgets, state rules, and interaction patterns for enterprise list views that remain usable from small datasets through 10K-row workloads — without per-feature drift or inconsistent UX decisions.
The Problem
List pages are deceptively complex. Sorting, filtering, bulk selection, empty states, permissions, pagination, virtualization, and responsive behaviour all create opportunities for implementation drift across teams. Without shared standards, each feature area was making independent decisions — some loading all rows into the DOM, some using flat 10 s polling intervals, some persisting filter state where it should reset, some not.
The goal was a repeatable model: one set of performance budgets, state storage rules, and interaction patterns that any team could reference when building or reviewing a list view, without needing a case-by-case design review for every feature.
Measured Performance Baselines
Before writing rules, I measured the existing list rendering performance using the platform’s question panel as a representative component. The results made the scale thresholds concrete rather than arbitrary:
| Row count | LCP (s) ≤ 2.5 good | INP (ms) ≤ 200 good | CLS ≤ 0.1 good |
|---|---|---|---|
| 100 | 1.62 | 207 | 0.26 |
| 1,000 | 2.86 | 625 | 0.03 |
| 5,000 | 5.83 | 3,085 | 0.23 |
| 10,000 | 9.12 | 5,254 | 0.03 |
Interactivity (INP) degrades before load time (LCP) does. At 1,000 rows the page loads in a borderline 2.86 s but interactions are already slow at 625 ms. This gave us a clear architectural breakpoint: frontend rendering is viable up to roughly 1,000 rows with optimisations; beyond that, the work must shift to the backend.
Architecture decision
For datasets expected to exceed ~1,000 rows, move filtering, sorting, and grouping
to the backend and return paginated or server-filtered results. For smaller datasets,
frontend computation within a 100 – 150 ms interaction budget is
acceptable with debounce, trackBy, and
untracked() applied.
Virtualization
When introducing virtual scrolling, aim for consistent row heights. Angular CDK virtual scrolling works reliably with fixed heights; the auto-size strategy for variable-height rows is experimental and can cause blank regions or scroll bar stutters. Custom height estimation is possible but significantly increases maintenance cost against future layout changes.
The practical rule: if rows can be made a consistent height without sacrificing content, prefer fixed-height virtual scrolling. If content varies significantly (e.g. multi-line descriptions, expandable details), invest in the auto-size approach only if the dataset is expected to regularly exceed a few hundred rows.
Loading States
Skeleton animations should be used on any design-system component that provides one. If a component doesn’t have a skeleton, a section- or panel-level loading indicator is required. Dependent interactions (bulk actions, filters, form controls) must be blocked until data is ready to prevent partial or invalid user actions.
State Management: Where Things Live
One of the most common inconsistencies was where UI state was persisted. The standard defines three tiers:
| Storage tier | What belongs here | Clears on |
|---|---|---|
| Component / signal | Selections, text search input, scroll position | Route change |
| Session storage | Filters, sorting, group expansion state | Tab / browser close |
| Local storage | Column chooser configuration, heavy layout preferences | Explicit user reset |
| Database | Saved views, named filters, anything the user explicitly saves | User action only |
The principle: if a setting is lightweight and the user wouldn’t be bothered by resetting it on their next visit, use session storage. If reproducing it is tedious (e.g. configuring a 12-column grid layout), persist it locally. If the user explicitly named and saved it, it belongs in the database.
Filtering, Sorting, and Grouping
- Filters, sort state, and group expansion should persist across in-page interactions and drill-downs. Route changes clear scroll position and selections only — not filter state.
- Every filter or sort must have a one-click path back to default state. Users should never have to hunt to reset a view.
- Filtering and bulk actions work in tandem: the filtered dataset is the subset the bulk action operates on.
- Fields are sortable by default unless explicitly excluded.
- Data interactivity target: 200 ms end-to-end. With Angular change detection (20 – 50 ms) and virtual scroll recalculation (10 ms) in the budget, filter computation has roughly 100 – 150 ms to complete.
Grouped datasets
When filtering a grouped dataset, retain parent/lineage nodes for structural context but visually de-emphasise them and exclude them from result counts. When sorting a grouped dataset, hierarchy takes priority — nodes sort among their siblings, not globally across the flat list.
Empty and Error States
Three distinct states — each requires its own treatment:
- No results from filter. “No results match your criteria” with a clear path to reset the filter.
- No data exists. Distinct empty-state component with context-specific messaging and a primary action (e.g. “Create your first report”).
- Load error. Toast notification for the error, and an inline component state explaining what failed and what to do next.
These were identified as a gap: the platform lacked reusable empty-state and error components with standardised messaging, leading to ad-hoc implementations across feature areas.
Selection and Bulk Actions
For selections that feed into form submission or need validation and dirty tracking,
use Angular Reactive Forms. For transient UI selection state that just drives an
action, Angular Signals or Angular CDK’s SelectionModel are
appropriate.
When a user changes filter criteria while holding selections, the default is to persist those selections (users often want to select across multiple filtered subsets). This requires surfacing a clear count or summary of current selections so users know their prior choices are still in effect.
If an operation has performance limits, align the maximum selection count with the pagination size, warn the user when they approach the threshold, and break large requests into batched async queues rather than blocking a single large call.
Impact
The standard gave teams a shared reference for list-view decisions, reducing the number of inconsistencies caught during PR review and design QA. It also formed the basis for Cursor AI rules — committed alongside the documentation so the tooling could enforce the patterns automatically during implementation.
Note
This document was a proposal and guideline, not a completed implementation audit. Several outstanding gaps were identified — including skeleton loader coverage, reusable empty-state components, and virtual scrolling rollout across all list views — and noted as follow-up work.