Skip to content

[CSM Portal] Cap and stagger dashboard widget loading to reduce backend load - #1437

Merged
Rashmika998 merged 5 commits into
wso2-open-operations:mainfrom
rksk:fix/dashboard-widget-load-concurrency-cap
Aug 12, 2026
Merged

[CSM Portal] Cap and stagger dashboard widget loading to reduce backend load#1437
Rashmika998 merged 5 commits into
wso2-open-operations:mainfrom
rksk:fix/dashboard-widget-load-concurrency-cap

Conversation

@rksk

@rksk rksk commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Purpose

Describe the problems, issues, or needs driving this feature/fix and include links to related issues in the following format: Resolves issue1, issue2, etc.

Loading a CSM dashboard fires one data-fetch request per widget (and one per pie slice) essentially simultaneously, with no coordination between tiles. On dashboards with ~20 widgets, this produces bursts of concurrent downstream calls large enough that the entity-service can't service them within its own timeout budget, producing request timeouts and 5xx responses back to the browser for a chunk of the widgets on a normal page load — this is a production-observed issue, not a hypothetical.

Goals

Describe the solutions that this feature/fix will introduce to resolve the problems described above

  • Bound how many widget fetches can be in flight at once.
  • Don't even attempt a fetch for a widget that isn't visible yet.
  • Abort a fetch that's taking too long, so one slow widget can't block every widget behind it.
  • Give a timed-out widget one more chance later instead of just failing it outright.

Approach

Describe how you are implementing the solutions. Include an animated GIF or screenshot if the change affects the UI (email documentation@wso2.com to review all UI text). Include a link to a Markdown file or Google doc if the feature write-up is too long to paste here.

  • Concurrency cap (widgetFetchConcurrency.ts): a small hand-rolled FIFO semaphore, withWidgetFetchSlot, wraps the actual API call inside useWidgetData/useWidgetPieData. React Query's own loading state is untouched — a queued widget simply shows its existing loading skeleton for longer. Concurrency is currently set to 1 (fully sequential) via the WIDGET_FETCH_CONCURRENCY_LIMIT constant.
  • Viewport-gated (lazy) loading (useElementVisibleOnce.ts): a one-shot IntersectionObserver latch — a widget's fetch doesn't fire until its tile has been observed on-screen at least once; once visible, it stays "loaded" (no refetch on scrolling away). Falls back to "load immediately" when IntersectionObserver isn't available in the runtime.
  • Client-side timeout (withWidgetFetchSlot + client.ts): each widget fetch is aborted via AbortController after WIDGET_FETCH_TIMEOUT_MS (10s), and — critically — the concurrency slot is released on timeout so the next queued widget can proceed.
  • One retry: a timed-out (or 502/503) widget gets exactly one retry, via a scoped retry predicate on the widget queries only (not the app's global retry policy). The retry re-enters the same FIFO queue, so it naturally lands after every widget that hadn't been attempted yet — no special priority, no re-created head-of-line blocking.

No dashboard config, entity-service, BFF, or Ballerina changes — frontend-only.

User stories

Summary of user stories addressed by this change

As a CS engineer, opening a dashboard with many widgets no longer causes several of them to fail with a backend error — widgets load progressively, a slow one degrades to a retry instead of blocking everything else, and off-screen widgets don't load until scrolled into view.

Release note

Brief description of the new feature or bug fix as it will appear in the release notes

Dashboard widgets now load with bounded concurrency, are deferred until scrolled into view, and time out with a retry instead of hanging indefinitely — reducing backend load and eliminating a class of widget-load failures on dashboards with many widgets.

Documentation

Link(s) to product documentation that addresses the changes of this PR. If no doc impact, enter “N/A” plus brief explanation of why there’s no doc impact

N/A — internal loading-behavior change, no user-facing config or documented API surface affected.

Training

N/A — no training content affected.

Certification

N/A — no certification exam content affected.

Marketing

N/A — internal reliability fix, not a marketed feature.

Automation tests

  • Unit tests

    Code coverage information

New/updated test files: widgetFetchConcurrency.test.ts, useElementVisibleOnce.test.ts, useWidgetData.test.tsx, DashboardWidgetGrid.lazyLoad.test.tsx, DashboardWidgetGrid.realisticViewport.test.tsx, plus updates to DashboardWidgetTile.test.tsx and useWidgetPieData call sites. Full dashboard/hooks regression suite: 26 files / 266 tests pass. tsc -b and eslint clean.

  • Integration tests

    Details about the test cases and coverage

Covered via component-level tests exercising the real DashboardWidgetGrid fan-out (not mocked in isolation): concurrency cap actually binds under 27 concurrent callers; a realistic 20-widget/4-per-row viewport fixture proves only genuinely on-screen widgets fetch; a timeout-then-retry ordering test proves the retry fires only after other queued widgets have already resolved, not before.

Security checks

  • Followed secure coding standards in http://wso2.com/technical-reports/wso2-secure-engineering-guidelines? yes
  • Ran FindSecurityBugs plugin and verified report? N/A — TypeScript/React change, no Java/Go code touched; eslint and tsc -b ran clean instead
  • Confirmed that this PR doesn't commit any keys, passwords, tokens, usernames, or other secrets? yes

Samples

N/A — no new samples.

Related PRs

None.

Migrations (if applicable)

N/A — no schema or data migration.

Test environment

Verified locally via vitest/tsc/eslint, and manually against a live local CSM backend stack (dashboard widget grids for multiple real dashboard configs), Chrome (latest).

Learning

Describe the research phase and any blog posts, patterns, libraries, or add-ons you used to solve the problem.

Root-caused via production Choreo logs (csm-portal-backend + customer-entity-service) showing bursts of simultaneous /cases/search timeouts correlated with dashboard page loads. No new dependency added — checked package.json for p-limit/p-queue first; the concurrency/timeout/retry mechanisms needed were each small enough to hand-roll instead.

Summary by CodeRabbit

  • New Features

    • Dashboard widgets now load as they enter the viewport, reducing unnecessary data requests.
    • Widget requests are queued, cancelled after timeouts, and retried for temporary failures.
    • Backend requests support cancellation signals.
    • Widget previews remain in a loading state until they are ready to fetch.
  • Bug Fixes

    • Improved dashboard loading behavior to prevent excessive or delayed requests.
    • Ensured failed and timed-out requests release resources and allow queued widgets to continue loading.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@rksk, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a751e074-d587-435e-8e51-274fad505b3f

📥 Commits

Reviewing files that changed from the base of the PR and between 2e435fc and 9fc0911.

📒 Files selected for processing (1)
  • apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.test.ts
📝 Walkthrough

Walkthrough

The PR adds abort-signal support to backend POST requests, shared widget-fetch concurrency and retry control, and viewport-gated dashboard widget loading. It also adds tests for cancellation, retries, visibility behavior, and realistic lazy-loading scenarios.

Changes

Widget request cancellation

Layer / File(s) Summary
Backend abort contract
apps/csm-portal/webapp/src/api/backend/client.ts, apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx, apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.test.tsx, apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx, apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.test.tsx
BackendApi.post accepts an optional AbortSignal and forwards it to fetch. Request assertions validate the third options argument.
Widget fetch control
apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.ts, apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts, apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.ts, apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.test.ts, apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.test.tsx
Widget requests use a FIFO queue with one active fetch, timeout-triggered aborts, guaranteed slot release, and one retry for timeout or HTTP 502/503 failures. Tests cover queueing, timeout, retry order, and terminal failure.

Viewport-gated dashboard loading

Layer / File(s) Summary
Visibility hook and tile integration
apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.ts, apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.test.ts, apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx, apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx, apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.ts
useElementVisibleOnce tracks one-time viewport visibility. Dashboard tiles enable count, list, pie, and bar data loading after visibility and remain in a loading state while offscreen.
Dashboard lazy-loading validation
apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx, apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx
Tests verify deferred off-screen requests, one-time loading after intersection, and eight visible requests in a 20-widget four-column layout.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels: Type/Bug

Suggested reviewers: rashmika998

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: limiting and staggering dashboard widget loading to reduce backend load.
Description check ✅ Passed The description covers the required sections and clearly explains the problem, implementation, testing, security checks, and release impact.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx (1)

130-138: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Key the simulated geometry to the node, not to a global observe counter.

observe() derives the row index from nextObserveIndex, which increments on every call. disconnect() is a no-op, so a second observe() for the same tile consumes a new index and shifts every later tile's simulated position. React can run an effect more than once per element, for example under StrictMode or after a ref reattachment. If that happens, the expected count of 8 changes for a reason unrelated to the hook.

Map each node to a stable index on first observe.

♻️ Proposed change
 class GeometryIntersectionObserver {
   static instances: GeometryIntersectionObserver[] = [];
   static nextObserveIndex = 0;
+  static nodeIndexes = new Map<Element, number>();
 
   callback: IntersectionObserverCallback;
   marginPx: number;
   threshold: number;
@@
   observe(node: Element): void {
-    const index = GeometryIntersectionObserver.nextObserveIndex;
-    GeometryIntersectionObserver.nextObserveIndex += 1;
+    let index = GeometryIntersectionObserver.nodeIndexes.get(node);
+    if (index === undefined) {
+      index = GeometryIntersectionObserver.nextObserveIndex;
+      GeometryIntersectionObserver.nextObserveIndex += 1;
+      GeometryIntersectionObserver.nodeIndexes.set(node, index);
+    }
     const visible = isVisible(index, this.marginPx, this.threshold);

Reset the map in beforeEach next to the existing counter reset:

     GeometryIntersectionObserver.instances = [];
     GeometryIntersectionObserver.nextObserveIndex = 0;
+    GeometryIntersectionObserver.nodeIndexes = new Map();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx`
around lines 130 - 138, Update GeometryIntersectionObserver.observe to assign
each node a stable index on its first observation and reuse that index for
subsequent observations of the same node, instead of incrementing
nextObserveIndex on every call. Add the corresponding node-to-index map and
reset it in beforeEach alongside the existing counter reset, preserving the
current visibility calculation and callback behavior.
apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx (1)

149-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Flush a macrotask before the steady-state assertions, and drop the unused mapping.

Line 153 repeats the assertion that Line 147 already awaited. No macrotask runs between them, so a wrongly-gated third fetch would not yet be visible. Flush a macrotask inside act before the assertion. Apply the same flush before Line 182, which asserts the same steady state after reporting isIntersecting: false.

Lines 149-150 compute fetchedIds from the request bodies but never check identity, and the length check duplicates Line 147. Either assert the two fetched widget identities, or remove the two lines.

♻️ Proposed change
-    const fetchedIds = postMock.mock.calls.map(([, body]) => body);
-    expect(fetchedIds).toHaveLength(2);
-    // Give the (deliberately un-triggered) third tile a chance to have
-    // fired if the gating didn't hold — it must still not have.
-    expect(postMock).toHaveBeenCalledTimes(2);
+    // Give the (deliberately un-triggered) third tile a chance to have
+    // fired if the gating didn't hold — it must still not have.
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 0));
+    });
+    expect(postMock).toHaveBeenCalledTimes(2);

Based on learnings, do not rely on an unflushed negative assertion to detect delayed TanStack Query requests; flush a macrotask in act first.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx`
around lines 149 - 153, Update the lazy-load test’s steady-state assertions by
flushing a macrotask inside act before the assertion after the initial
intersection and again before the equivalent assertion after reporting
isIntersecting: false, so delayed TanStack Query requests are observable. In the
fetchedIds section, either assert the two expected widget identities or remove
the unused mapping and redundant length assertion, while retaining the
request-count check.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.test.ts`:
- Around line 25-59: Update the withWidgetFetchSlot test to assert FIFO start
order by comparing the recorded started array with the original request indices
in order. Keep the existing concurrency and result assertions unchanged, and add
the assertion after all calls have completed.

---

Nitpick comments:
In
`@apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx`:
- Around line 149-153: Update the lazy-load test’s steady-state assertions by
flushing a macrotask inside act before the assertion after the initial
intersection and again before the equivalent assertion after reporting
isIntersecting: false, so delayed TanStack Query requests are observable. In the
fetchedIds section, either assert the two expected widget identities or remove
the unused mapping and redundant length assertion, while retaining the
request-count check.

In
`@apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx`:
- Around line 130-138: Update GeometryIntersectionObserver.observe to assign
each node a stable index on its first observation and reuse that index for
subsequent observations of the same node, instead of incrementing
nextObserveIndex on every call. Add the corresponding node-to-index map and
reset it in beforeEach alongside the existing counter reset, preserving the
current visibility calculation and callback behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2285c316-4723-4aa8-aaed-7a16fba0c5d7

📥 Commits

Reviewing files that changed from the base of the PR and between bfda79e and 2e435fc.

📒 Files selected for processing (15)
  • apps/csm-portal/webapp/src/api/backend/client.ts
  • apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.test.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts
  • apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.test.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetPieData.ts
  • apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.lazyLoad.test.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.realisticViewport.test.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.test.tsx
  • apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.test.ts
  • apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetFetchConcurrency.ts
  • apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.test.ts
  • apps/csm-portal/webapp/src/hooks/useElementVisibleOnce.ts

@Rashmika998
Rashmika998 merged commit d0bb1cc into wso2-open-operations:main Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants