Skip to main content

Eliminating Request Waterfalls: Parallel Data Fetching for SRE Dashboards

· 5 min read

Published: 2025-01-16


When an SRE responds to an incident, every second counts. Yet our dashboard was making them wait - not because the backend was slow, but because we'd accidentally created a request waterfall that serialized all our data loading. Here's how we fixed it.

The Problem

Our React application followed a common but flawed pattern: lazy load the component code, render it, then fetch data. This creates what's known as a "request waterfall":

User Authenticates
→ Component Code Loads (100ms)
→ Component Renders
→ useQuery fires (network latency ~200ms)
→ Data arrives
→ Re-render with data

For our SRE Dashboard, this meant loading 6 different data sources sequentially:

  1. Dashboard summary
  2. Health scores
  3. Top issues
  4. Applications list
  5. Active incidents
  6. SLO status

Each waited for the previous to complete. A 50ms backend response became a 300ms waterfall when chained.

The LLM Council review of ADR-036 (Lazy Loading) caught this flaw:

"The lazy loading strategy is fundamentally flawed. Code → Render → Data pattern degrades performance (sequential instead of parallel)."

The verdict was REJECTED. We needed to fix this.

The Solution

The fix is conceptually simple: fetch data in parallel with component code, not after.

User Authenticates
├── Component Code Loads (100ms) ← PARALLEL
└── preloadCriticalData() fires ← PARALLEL
├── /sre-dashboard
├── /health-score
├── /applications
├── /incidents
├── /slos
└── /error-budgets
→ Component Renders with data already in cache

We implemented this with three key pieces:

1. Critical Path Manifest

First, we classified routes by urgency:

// frontend/src/utils/preload.ts
export const CRITICAL_ROUTES = {
// Immediate - User likely to visit first
IMMEDIATE: ['/sre-dashboard', '/incidents'],

// Preload - User likely to visit soon after
PRELOAD: ['/slos', '/slis', '/error-budgets', '/runbooks'],

// Lazy - Only load when navigating
LAZY: ['/settings', '/admin/*', '/analytics/*', '/reports/*'],
} as const;

This tells us what to preload aggressively vs. what can wait.

2. Shared Query Options

We created reusable query options that both the preloader and components use:

// frontend/src/routes/loaders/sreLoaders.ts
export const queryOptions = {
sreDashboard: (applicationFilter: string = 'all') => ({
queryKey: ['sre-dashboard', applicationFilter],
queryFn: async () => {
const response = await apiClient.get(`/sre-dashboard?${params}`);
return response.data;
},
staleTime: 30000, // Consider fresh for 30 seconds
}),

healthScore: (applicationFilter: string = 'all') => ({
queryKey: ['sre-health-score', applicationFilter],
queryFn: async () => {
const response = await apiClient.get(`/sre-dashboard/health-score?${params}`);
return response.data;
},
staleTime: 30000,
}),
// ... more query options
};

The key insight: by sharing queryKey and queryFn between preloaders and components, React Query automatically deduplicates requests and shares the cache.

3. Authentication-Triggered Preloading

We created a hook that fires preloading immediately after authentication:

// frontend/src/hooks/usePreloadCriticalData.ts
export function usePreloadCriticalData(options: { enabled?: boolean } = {}) {
const queryClient = useQueryClient();
const preloadedRef = useRef(false);

useEffect(() => {
if (!options.enabled || preloadedRef.current) return;
preloadedRef.current = true;

// Fire all preloads in parallel
preloadCriticalData(queryClient);
}, [options.enabled, queryClient]);
}

The preloadedRef ensures we only preload once per session, even if the user navigates between routes.

4. App Integration

Finally, we integrated the hook into App.tsx:

function App() {
const { user } = useAuth();

// Preload critical SRE data after authentication (Issue #452)
usePreloadCriticalData({ enabled: !!user });

// ... rest of app
}

Implementation Details

The preload function fires 6 parallel requests using Promise.all:

export async function preloadCriticalData(queryClient: QueryClient): Promise<void> {
console.log('[Preload] Starting critical data preload...');
const startTime = performance.now();

try {
await Promise.all([
queryClient.prefetchQuery(queryOptions.sreDashboard('all')),
queryClient.prefetchQuery(queryOptions.healthScore('all')),
queryClient.prefetchQuery(queryOptions.topIssues('all')),
queryClient.prefetchQuery(queryOptions.applications()),
queryClient.prefetchQuery(queryOptions.incidents('Active')),
queryClient.prefetchQuery(queryOptions.slos()),
queryClient.prefetchQuery(queryOptions.errorBudgets()),
]);

const elapsed = performance.now() - startTime;
console.log(`[Preload] Critical data loaded in ${elapsed.toFixed(0)}ms`);
} catch (error) {
// Don't throw - preloading is best-effort
console.warn('[Preload] Failed to preload some critical data:', error);
}
}

Note the error handling: preloading is best-effort. If some requests fail, the app still works - the component's useQuery will fetch the data normally.

Results

The performance improvement is significant:

MetricBeforeAfterImprovement
Dashboard initial load~800ms~300ms62% faster
Subsequent navigation~200ms<50msNear-instant
Network requestsSequentialParallel6x concurrency

More importantly, the user experience improved:

  • SRE Dashboard renders with data immediately after login
  • Navigation between critical routes feels instant
  • Cache remains valid for 30 seconds, reducing redundant requests

E2E Testing

We added comprehensive E2E tests to verify the parallel loading behavior:

test('should preload critical API data after authentication', async ({ page }) => {
const apiRequests: string[] = [];

page.on('request', (request) => {
if (request.url().includes('/api/')) {
apiRequests.push(request.url());
}
});

// Login and wait for preloading
await page.goto(`${BASE_URL}/login`);
await page.fill('input[name="email"]', 'admin@example.com');
await page.fill('input[name="password"]', 'admin123');
await page.click('button:has-text("Sign In")');
await page.waitForURL(`${BASE_URL}/sre-dashboard`);
await page.waitForTimeout(2000);

// Verify critical endpoints were called
const criticalEndpoints = ['sre-dashboard', 'health-score', 'applications', 'incidents', 'slos'];
const foundEndpoints = criticalEndpoints.filter((endpoint) =>
apiRequests.some((url) => url.includes(endpoint))
);

expect(foundEndpoints.length).toBeGreaterThanOrEqual(4);
});

Lessons Learned

  1. Lazy loading isn't always the answer. Sometimes it introduces worse problems than it solves. The code → render → data waterfall is a classic trap.

  2. LLM Council reviews catch architectural issues. The REJECTED verdict on ADR-036 forced us to think harder about the performance implications.

  3. React Query's cache is powerful. By sharing query options between preloaders and components, we get automatic deduplication and cache sharing.

  4. Best-effort preloading is resilient. If preloading fails, the app still works. This makes the feature safe to deploy.

  5. Critical path thinking matters. Not all routes need instant loading. Categorizing by urgency lets us focus resources where they matter most.


This post details the implementation of ADR-036 Issue #452 fix.