karthik.dev
Back to blogEngineering

How I Built a Real-Time LeetCode Stats Dashboard with 2-Minute Polling

2026-08-02 · 7 min read

Most portfolio sites hardcode their LeetCode stats. Mine refreshes every two minutes from a live API. Here's exactly how I built it — the architecture decisions, the edge cases, and the parts that surprised me.

The problem with direct client-side fetching

The obvious approach — calling the LeetCode API from the browser — runs into CORS immediately. LeetCode's official API doesn't expose public CORS headers, and third-party wrappers like alfa-leetcode-api are deployed on free Render instances that spin down after inactivity, meaning the first request after a cold start might take 20–30 seconds.

Hitting that from a client component means your entire UI stalls while waiting.

The server-side proxy pattern

The fix is a Next.js API route acting as a proxy. The browser calls /api/leetcode (same origin, no CORS), and the route handler calls the upstream API server-side with a 6-second timeout and a hardcoded fallback:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 6000);

const [solvedRes, profileRes] = await Promise.all([
  fetch(`${BASE}/${USERNAME}/solved`, { signal: controller.signal }),
  fetch(`${BASE}/${USERNAME}`, { signal: controller.signal }),
]);
clearTimeout(timer);

If either call fails or times out, the route returns the last-known accurate stats with isFallback: true — so the UI always has something meaningful to show.

Cache-Control: the secret ingredient

The route returns Cache-Control: public, s-maxage=120, stale-while-revalidate=60. This means:

  • A CDN (Vercel Edge, Cloudflare) caches the response for 2 minutes
  • After 2 minutes, it serves stale data while regenerating in the background
  • The browser never waits for a cold API call

Client-side polling at the same cadence

The component sets up a setInterval matching the server cache duration:

const POLL_MS = 2 * 60 * 1000;

useEffect(() => {
  fetchStats(true);
  intervalRef.current = setInterval(() => fetchStats(false), POLL_MS);
  return () => clearInterval(intervalRef.current);
}, [fetchStats]);

The UI distinguishes between the initial load (full skeleton) and background refreshes (spinning indicator) so the user never sees a jarring blank state.

The Live/Offline badge

One detail I'm proud of: a badge that shows whether the data came from the live API or the fallback. The API route returns isFallback: boolean in every response, and the component maps that to a green LIVE badge or a red OFFLINE badge with a WiFi icon.

It also shows "Updated X minutes ago" by computing the delta from the fetchedAt timestamp every 15 seconds client-side — cheap and accurate without an extra API call.

What I'd improve next

The free Render deployment that hosts alfa-leetcode-api cold-starts unpredictably. The right long-term fix is a self-hosted scraper with a Redis cache sitting in front of it, so warm data is always sub-10ms. That's on the roadmap.