Lessons learned from Pump.fun's 10x React Native startup improvement and how they apply to crypto-vision.
- The Problem
- Server-Side: WebSocket Broadcast Throttling
- Mobile: Replace Polling with WebSocket + Client-Side Throttling
- Mobile: Memoize StyleSheet Creation
- Mobile: Performance Telemetry
- Dashboard: Tailwind Class Validation
- Architecture Principles
- Checklist for New Features
Real-time crypto apps face a specific performance challenge: high-frequency data meets expensive rendering. Pump.fun documented receiving ~1,000 trades/second per coin, with screens showing 10+ coins simultaneously — potentially 10,000 events/second.
Our crypto-vision platform faces the same class of problem:
- CoinCap WebSocket emits continuous price ticks for 10+ coins
- The mobile app shows 50 coins on the Markets screen
- The dashboard shows real-time prices, charts, and sentiment
| Metric | Before | After |
|---|---|---|
| CSS interop CPU usage | 3.5% | 0.01% |
| App startup (iOS) | 1.5s | 110ms |
| Route change speed | baseline | ~10% faster |
Their key insight: runtime style computation was the dominant cost, and moving it to build-time eliminated it entirely.
File: src/lib/ws.ts
CoinCap sends price ticks as fast as they arrive. Previously, every tick was immediately broadcast to all connected clients. Now, price updates are buffered and flushed at 5 Hz (200ms intervals):
CoinCap tick (100+/sec) → pendingPrices buffer → flush at 5Hz → per-client filtered broadcast
Pump.fun determined that 5 updates/second is the sweet spot:
- Human perception of numeric changes tops out around 4-8 Hz
- React/React Native can comfortably render at this rate without frame drops
- Even with 10 coins visible, that's only 50 state updates/second
// Latest price per coin is accumulated (last-write-wins)
const pendingPrices = new Map<string, string>();
// Every 200ms, flush all accumulated prices as one batch
setInterval(() => {
if (pendingPrices.size === 0) return;
const batch = Object.fromEntries(pendingPrices);
pendingPrices.clear();
broadcastRaw("prices", JSON.stringify({ type: "price", data: batch, ... }));
}, 200);This reduces downstream client processing from hundreds of messages/second to exactly 5, regardless of upstream volume.
File: apps/news/mobile/src/hooks/useWebSocket.ts
The mobile app used setInterval polling at fixed rates:
- Market coins: 30s polling (
useMarketCoins) - Coin price: 10s polling (
useCoinPrice) - Fear & Greed: 60s polling (
useFearGreed)
This means prices could be up to 30 seconds stale and every poll makes a full HTTP round-trip.
The useWebSocket hook provides:
- Persistent WebSocket connection — single TCP connection, instant updates
- Client-side throttle buffer — accumulates data, flushes to React state at configurable Hz
- Exponential backoff reconnection — with jitter, up to 30s max delay
- Heartbeat monitoring — detects stale connections within 45s
// Subscribe to live prices for visible coins
const { data: prices, status } = useLivePrices(['bitcoin', 'ethereum', 'solana']);
// prices updates at most 5 times/second — React re-renders are boundeduseLivePrices(coins) wraps the generic useWebSocket with the correct URL and message parsing for the /ws/prices endpoint.
File: apps/news/mobile/src/hooks/useStyles.ts
Every component used this pattern:
function CoinCard({ coin }) {
const isDark = useColorScheme() === 'dark';
const styles = createStyles(isDark); // ← StyleSheet.create() on EVERY render
// ...
}For a FlatList of 50 CoinCards, this means 50 × StyleSheet.create() calls per scroll frame. This is the same problem Pump.fun found with Nativewind's cssInterop consuming 3.5% of CPU.
function CoinCard({ coin }) {
const styles = useStyles(coinCardStyles); // ← Memoized, only recomputes on theme change
// ...
}useStyles wraps useMemo so StyleSheet.create() is only called when the color scheme actually changes (light ↔ dark), not on every render.
Centralized theme values eliminate scattered ternaries:
const t = getTheme(isDark);
// t.card, t.text, t.textSecondary, t.border, t.positive, t.negativeFile: apps/news/mobile/src/hooks/usePerformanceMonitor.ts
| Metric | How | Why |
|---|---|---|
| JS FPS | requestAnimationFrame counting |
Detect frame drops like Pump.fun's 20 FPS observations |
| Average FPS | 10-sample rolling window | Smooth out spikes |
| Render count | Ref counter in hook | Track excessive re-renders |
| Slow render warnings | Timestamp diff | Catch renders >16ms (1 frame budget) |
| Screen attribution | screenName parameter |
Know which screen is slow |
function MarketsScreen() {
const perf = usePerformanceMonitor('MarketsScreen');
// In dev, slow renders auto-warn in console
// perf.isLowFPS === true when JS thread is struggling
}Samples accumulate in a buffer and flush when full (100 samples) or on unmount:
setPerformanceTelemetryHandler((samples) => {
// Send to DataDog, Sentry, or your analytics backend
fetch('/api/telemetry', {
method: 'POST',
body: JSON.stringify({ perf: samples }),
});
});throttle(fn, hz)— Cap callback frequency (e.g., price update handlers)debounce(fn, ms)— Wait for silence (e.g., search input)
File: apps/dashboard/eslint.config.mjs
"We had about a dozen classes that flat out didn't exist and even more 'in use' that didn't actually apply to React Native."
The same problem exists in web apps — typos in class names silently do nothing.
| Rule | Purpose |
|---|---|
tailwindcss/no-custom-classname |
Flags classes that don't exist in tailwind config |
tailwindcss/no-contradicting-classname |
Catches p-2 p-4 on same element |
tailwindcss/classnames-order |
Enforces consistent ordering |
tailwindcss/no-unnecessary-arbitrary-value |
Prefers utilities over [value] syntax |
Our custom design-token classes (CSS variable based) are whitelisted to avoid false positives.
cd apps/dashboard
npm install -D eslint-plugin-tailwindcssThese are the general principles from Pump.fun's article applied to our codebase:
- Pump.fun: Nativewind (runtime) → React Native Tailwind (build-time)
- Us: If we adopt Tailwind for mobile, start with build-time compilation (RNT)
- General: Anything computable at build time should NOT run at render time
Upstream source (1000/sec)
→ Server throttle (5 Hz flush)
→ Client throttle (5 Hz state updates)
→ UI rendering (batched by React)
- Use release-build profiling (not dev mode)
- Add telemetry to every screen — attribute performance to specific routes
- Track JS FPS, not just network latency
- Catch invalid/unused CSS classes before they ship
- Block web-only classes from mobile code
- Enforce consistent patterns with ESLint
StyleSheet.create()results should be cached- Parse JSON once, not per-client
- Use
useMemofor derived data
When adding a new screen or component:
- Does it receive real-time data? → Use
useWebSocketwith throttle, not polling - Does it create styles? → Use
useStyles()hook, not inlinecreateStyles() - Does it use Tailwind classes? → Run
npm run lintto validate class names - Is it a heavy screen? → Add
usePerformanceMonitor('ScreenName') - Does it render a list? → Use
FlatListwithkeyExtractor, not.map() - Does it process frequent events? → Use
throttle(fn, 5)to cap at 5 Hz - Is the component pure? → Wrap with
React.memo()to skip unnecessary re-renders