--- title: Performance Audit tags: [mortgagefi, audit, performance] type: audit status: reference updated: 2026-06-14 --- # Performance Audit **Audit Date:** 2026-05-01 **Scope:** Next.js frontend, nftcache (Go), embedded scheduler API, Docker builds, blockchain interaction patterns **Overall Rating:** 🔎 **HIGH IMPACT** — Multiple performance bottlenecks affect user experience, RPC costs, and scalability. > [!warning] Overall Rating: HIGH IMPACT > Multiple performance bottlenecks affect user experience, RPC costs, and scalability. --- ## Executive Summary | Component | Score | Biggest Issue | |-----------|-------|---------------| | Frontend (DApp) | 🔎 Poor | 1,400-line monolithic component with 15 `useEffect`, 0 `useCallback`, and unbatched RPC reads | | nftcache | 🟡 Fair | Serial HTTP scanning (1 round-trip per token); no connection pooling | | Scheduler API | 🟡 Fair | N+1 Redis queries; sequential task execution | | Docker / Build | 🟡 Fair | 69MB `.next/` output; stale build args referencing removed service | | Blockchain Reads | 🔎 Poor | 14+ separate `useReadContract` hooks that should be batched | > [!info] Estimated impact > - **Frontend:** ~3-5 second time-to-interactive on mobile; janky re-renders on every input keystroke > - **nftcache:** 10-60 seconds to scan a 1,000-token contract; RPC rate limit exhaustion under load > - **Scheduler:** O(n) Redis round-trips for n due tasks; cron can take >60s with retries --- ## 1. Frontend Performance (Critical) ### F1 — Monolithic 1,400-Line Component with Zero Callback Memoization 🔎 **Location:** `app/dapp/page.tsx` (1,405 lines) > [!warning] Impact > Every keystroke, every state change, and every prop drift triggers a full re-render of the entire DApp UI. **Metrics:** - `useState`: 18 instances - `useEffect`: 15 instances - `useMemo`: 13 instances - `useCallback`: **0 instances** **Problem:** Without `useCallback`, every inline function (event handlers, `onToggle`, `setPaymentAmount`, `handlePay`, etc.) is recreated on every render. Any child component or memoized selector that receives these functions as props will see them as "changed" and re-render unnecessarily. **Example:** ```typescript // This function is recreated on EVERY render const setPaymentAmount = (tokenId: bigint, value: string) => { setPayInputs((prev) => ({ ...prev, [tokenId.toString()]: value })); }; ``` > [!tip] Remediation > 1. **Extract sub-components** — Split into `PositionCard`, `PaymentControls`, `ScanPanel`, `AlertToggle` > 2. **Memoize callbacks:** > ```typescript > const setPaymentAmount = useCallback((tokenId: bigint, value: string) => { > setPayInputs((prev) => ({ ...prev, [tokenId.toString()]: value })); > }, []); > > const handlePay = useCallback(async (tokenId: bigint) => { > // ... > }, [debtAddress, stableDecimals, payInputs, payMaxByTokenId, writeContractAsync, selectedChainId]); > ``` > 3. **Memoize expensive derived data** with stable dependencies --- ### F2 — Unstable `useReadContracts` Dependencies Cause Excessive Refetching 🔎 **Location:** `app/dapp/page.tsx:519-588` > [!warning] Impact > wagmi re-fetches debt data on every render even when token IDs haven't meaningfully changed. **Problem:** ```typescript const debtReads = useMemo(() => { if (!tokenIds.length) return [] as any[]; return tokenIds.flatMap((id) => [ { abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'openDebt', args: [id], chainId: selectedChainId }, // ... 6 more reads per token ]; }, [tokenIds, debtAddress, selectedChainId]); const { data: debtResults, refetch } = useReadContracts({ contracts: debtReads as any, query: { enabled: debtReads.length > 0 }, }); ``` `debtReads` is a new array of new objects on every render. wagmi's `useReadContracts` deep-compares the `contracts` array, but if the objects aren't stable, it can trigger unnecessary cache invalidation and re-fetching. > [!tip] Remediation > ```typescript > // Use stringify for stable comparison, or better: keep tokenIds stable > const debtReads = useMemo(() => { > // ... same logic > }, [tokenIds.join(','), debtAddress, selectedChainId]); > ``` > > Even better: **batch all static reads into a single `useReadContracts` call** instead of mixing `useReadContract` + `useReadContracts`. --- ### F3 — 14 Separate `useReadContract` Hooks Instead of One Batch 🔎 **Location:** `app/dapp/page.tsx:458-702` > [!warning] Impact > 14 individual RPC round-trips on every mount/chain switch instead of 1 batched multicall. **Current hooks:** 1. `supportsInterface` 2. `balanceOf` 3. `calculateAPR` 4. `stablecoin` 5. `contractCoin` 6. `decimals` (stable) 7. `symbol` (stable) 8. `decimals` (collateral) 9. `symbol` (collateral) 10. `decimals` (debt token) 11. `symbol` (debt token) 12. `balanceOf` (debt token) 13. `balanceOf` (collateral) 14. `balanceOf` (stable) 15. `allowance` **Problem:** Each `useReadContract` is a separate wagmi query. Even with HTTP batching, this creates 15 separate cache entries, 15 separate error boundaries, and 15 separate re-render triggers. > [!tip] Remediation > Collapse all static/global reads into a single `useReadContracts`: > ```typescript > const { data: globalReads } = useReadContracts({ > contracts: [ > { abi: erc721Abi, address: nftAddress, functionName: 'supportsInterface', args: ['0x780e9d63'] }, > { abi: erc721Abi, address: nftAddress, functionName: 'balanceOf', args: [effectiveWallet] }, > { abi: debtAbi, address: debtAddress, functionName: 'calculateAPR' }, > { abi: debtAbi, address: debtAddress, functionName: 'stablecoin' }, > { abi: debtAbi, address: debtAddress, functionName: 'contractCoin' }, > // ... all 15 reads in one array > ], > query: { enabled: !!nftAddress && !!debtAddress }, > }); > ``` > > This reduces re-render noise from 15 separate state changes to 1. --- ### F4 — Auto-Reschedule Effect Fires on Every `parsed` Mutation 🔎 **Location:** `app/dapp/page.tsx:948-974` > [!warning] Impact > Every time debt data refreshes, the effect loops over all positions and cancels/re-creates schedules. **Problem:** ```typescript useEffect(() => { (async () => { for (const row of parsed) { // ... checks drift, cancels old jobs, schedules new ones } })(); }, [parsed.map(r => String(r.tokenId)+':'+String(r.secondsTillLiq)).join(','), notifSettings.daysBefore, positionsStoreKey]); ``` `parsed.map(...).join(',')` creates a new string on every render. Even if `secondsTillLiq` hasn't changed, string coercion of `bigint` can be expensive, and the async IIFE fires unnecessarily. > [!tip] Remediation > 1. Use a stable hash or deep-equality check instead of string serialization > 2. Add a mutex/ref to prevent concurrent scheduling: > ```typescript > const isScheduling = useRef(false); > useEffect(() => { > if (isScheduling.current) return; > isScheduling.current = true; > (async () => { ... })().finally(() => { isScheduling.current = false; }); > }, [stableDependency]); > ``` --- ### F5 — No Code Splitting or Lazy Loading 🔎 **Location:** `app/dapp/page.tsx`, `app/layout.tsx` > [!warning] Impact > The entire DApp (~700KB+ of JS) downloads before the user sees anything. **Evidence:** - No `next/dynamic` imports anywhere - No `React.lazy` or `Suspense` - `SettingsModal` (~350 lines) is bundled into the initial chunk despite being rarely used - `framer-motion` is imported but may not be used on first paint > [!tip] Remediation > ```typescript > import dynamic from 'next/dynamic'; > > const SettingsModal = dynamic(() => import('@/components/SettingsModal'), { > loading: () =>
Loading...
, > }); > ``` > > Also lazy-load heavy Web3 libraries if possible: > ```typescript > const Web3Provider = dynamic(() => import('@/providers/Web3Provider'), { ssr: false }); > ``` --- ### F6 — localStorage Access in Render Path and Effects 🟡 **Location:** `app/dapp/page.tsx:85-96`, `components/SettingsModal.tsx:49-73` > [!warning] Impact > Synchronous localStorage reads block the main thread; JSON parsing on every mount. **Problem:** ```typescript useEffect(() => { if (typeof window !== 'undefined' && window.localStorage) { const ls = window.localStorage; setNftcacheEnabled(ls.getItem('nftcache:enabled') === '1'); // ... more reads and JSON parsing } }, []); ``` > [!tip] Remediation > Use a single initialization effect with `try/catch`, and persist via a custom hook that debounces writes: > ```typescript > function useLocalStorage(key: string, initial: T) { > const [value, setValue] = useState(() => { > try { > const raw = localStorage.getItem(key); > return raw ? (JSON.parse(raw) as T) : initial; > } catch { return initial; } > }); > useEffect(() => { > localStorage.setItem(key, JSON.stringify(value)); > }, [key, value]); > return [value, setValue] as const; > } > ``` --- ### F7 — Large JavaScript Bundle 🟡 **Location:** `.next/static/chunks/` > [!warning] Impact > 690KB+ of uncompressed JS across main chunks; ~293KB largest chunk. **Breakdown:** | Chunk | Size | Likely Contents | |-------|------|-----------------| | `63d1a7d9
js` | 293 KB | Main app chunk (wagmi + viem + Web3Modal) | | `612aa671
js` | 202 KB | Framework runtime (Next.js + React) | | `a6dad97d
js` | 110 KB | Component/framework code | | `a5e16e63
js` | 86 KB | Vendor libraries | **Total `.next/`: 69 MB** (includes standalone server, static assets, cache) > [!tip] Remediation > 1. Enable tree-shaking for `viem` by importing only needed modules: > ```typescript > import { parseUnits } from 'viem'; // ✅ good > import { Abi } from 'viem'; // ✅ good > ``` > 2. Replace `@web3modal/wagmi` with the newer `@reown/appkit` (smaller bundle) > 3. Remove unused dependencies (`@web3-react/core`, `@web3-react/injected-connector` if WalletConnect covers all wallets) > 4. Use `next/dynamic` for non-critical components --- ## 2. nftcache Performance (High) ### N1 — Serial HTTP Scanning (1 Round-Trip Per Token) 🔎 **Location:** `nftcache/internal/fetcher/rpc.go:55-113` > [!warning] Impact > Scanning 1,000 tokens takes **1,000 sequential HTTP requests** = 10-60 seconds depending on latency. **Problem:** ```go for i := 0; i < maxTokenId; i++ { tokenOwner, err := c.getOwnerOf(rpcURL, contract, i, debug) // ... } ``` Each `getOwnerOf` makes a full HTTP POST, waits for response, then proceeds to the next token. No batching, no pipelining. **Modern RPCs support `eth_call` batching:** ```json [ {"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x...","data":"0x6352211e..."},"latest"]}, {"jsonrpc":"2.0","id":2,"method":"eth_call","params":[{"to":"0x...","data":"0x6352211e..."},"latest"]}, // ... up to 100 per batch ] ``` > [!tip] Remediation > Implement batch RPC calls: > ```go > const batchSize = 100 > for batchStart := 0; batchStart < maxTokenId; batchStart += batchSize { > end := min(batchStart+batchSize, maxTokenId) > owners, err := c.getOwnerOfBatch(rpcURL, contract, batchStart, end) > // ... > } > ``` > > This reduces 1,000 HTTP round-trips to **10**, cutting scan time from ~60s to ~2-5s. --- ### N2 — No HTTP Connection Pooling 🟡 **Location:** `nftcache/internal/fetcher/rpc.go:21-23` > [!warning] Impact > TCP handshake overhead on every request. **Problem:** ```go http: &http.Client{}, // default — no connection reuse tuning ``` > [!tip] Remediation > ```go > http: &http.Client{ > Transport: &http.Transport{ > MaxIdleConns: 100, > MaxIdleConnsPerHost: 100, > IdleConnTimeout: 90 * time.Second, > }, > Timeout: 30 * time.Second, > }, > ``` --- ### N3 — Rate Limiter Bottleneck 🟡 **Location:** `nftcache/internal/fetcher/rpc.go:20-45` > [!warning] Impact > Hard-coded 5 TPS limits throughput even for batched requests. **Problem:** ```go rateLimiter: make(chan struct{}, 5), // 5 TPS ``` With batching, you could safely increase this to 20-50 TPS because each RPC call carries 100 `eth_call`s. The limit should be on RPC **calls**, not HTTP **requests**. --- ### N4 — Unbounded Background Refresh Goroutines 🟡 **Location:** `nftcache/cmd/nftcache/main.go:133-151` > [!warning] Impact > Under load, stale cache hits spawn unlimited goroutines, exhausting memory and RPC quota. **Problem:** ```go if age >= a.ttl { go func(net, contract string, maxTokenId int, key string) { // ... full rescan }(net, cContract, maxTokenId, contractCacheKey) } ``` > [!tip] Remediation > Use `sync.SingleFlight` to deduplicate: > ```go > import "golang.org/x/sync/singleflight" > > type app struct { > // ... > refreshGroup singleflight.Group > } > > // In getNFTs: > if age >= a.ttl { > go a.refreshGroup.Do(contractCacheKey, func() (interface{}, error) { > // ... refresh logic > return nil, nil > }) > } > ``` --- ## 3. Scheduler API Performance (Medium) ### S1 — N+1 Redis Queries in `listDueTasks` 🟡 **Location:** `lib/task-store.ts:45-56` > [!warning] Impact > If 50 tasks are due, this makes **51 Redis round-trips** (1 zrange + 50 gets). **Problem:** ```typescript export async function listDueTasks(before: number): Promise { const ids = await r.zrange(TASK_ZSET, 0, before, { byScore: true }); const tasks: Task[] = []; for (const id of ids) { const t = await getTask(id); // N individual GETs if (t) tasks.push(t); } return tasks; } ``` > [!tip] Remediation > Use `mget` for batch retrieval: > ```typescript > export async function listDueTasks(before: number): Promise { > const ids = await r.zrange(TASK_ZSET, 0, before, { byScore: true }); > if (!ids || ids.length === 0) return []; > > const keys = ids.map(id => `${TASK_PREFIX}${id}`); > const datas = await r.mget(keys); > > return datas > .map((data, i) => ({ data, id: ids[i] })) > .filter(({ data }) => data !== null) > .map(({ data, id }) => { > try { > return JSON.parse(data!) as Task; > } catch { > return null; > } > }) > .filter((t): t is Task => t !== null); > } > ``` > > This reduces 51 round-trips to **2**. --- ### S2 — Sequential Task Execution in Cron 🟡 **Location:** `app/api/cron/route.ts:25-65` > [!warning] Impact > If 10 tasks are due with 3 retries each at 5s intervals, cron takes `10 × 3 × 5s = 150s` — exceeding the 60s Vercel function limit. **Problem:** Tasks execute in a `for...of` loop with `await sleep()`. > [!tip] Remediation > Execute independent tasks in parallel with `Promise.all` and a concurrency limit: > ```typescript > import pLimit from 'p-limit'; > > const limit = pLimit(5); // max 5 concurrent webhook calls > > await Promise.all( > tasks.map(task => limit(() => executeTask(task))) > ); > ``` --- ### S3 — No Task Cleanup on Failure 🟡 **Location:** `app/api/cron/route.ts:56-62` > [!warning] Impact > Failed tasks accumulate in Redis forever, bloating storage and slowing down zrange scans. **Problem:** Only successful tasks are deleted. Failed tasks remain with their original score. > [!tip] Remediation > On final failure, delete the task or move it to a dead-letter set: > ```typescript > if (!ok) { > await deleteTask(task.id); > await redis.zadd('mortgagefi:tasks:dead', { score: nowSec, member: task.id }); > } > ``` --- ## 4. Docker & Build Performance (Medium) ### D1 — Stale Build Arguments in Dockerfile 🟡 **Location:** `mortgagefi-frontend/Dockerfile` > [!warning] Impact > Build still references removed `NEXT_PUBLIC_SCHEDY_URL`. ```dockerfile ARG NEXT_PUBLIC_SCHEDY_URL ENV NEXT_PUBLIC_SCHEDY_URL=${NEXT_PUBLIC_SCHEDY_URL:-/schedy} ``` > [!tip] Remediation > Remove the SCHEDY argument and add Redis/CRON secrets if needed for standalone server: > ```dockerfile > ARG CRON_SECRET > ARG UPSTASH_REDIS_REST_URL > ARG UPSTASH_REDIS_REST_TOKEN > ENV CRON_SECRET=${CRON_SECRET} > ENV UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL} > ENV UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN} > ``` --- ### D2 — Large `.next/` Output (69MB) 🟡 **Location:** `mortgagefi-frontend/.next/` > [!warning] Impact > Slow Docker builds, large image layers. **Breakdown:** - Standalone server: ~40MB - Static chunks: ~1.2MB - Cache, trace files, source maps: ~25MB > [!tip] Remediation > 1. Add `.dockerignore` to exclude `.next/cache`, `node_modules`, `.git`: > ``` > .next/cache > node_modules > .git > .env* > ``` > 2. Disable source maps in production: > ```typescript > // next.config.ts > productionBrowserSourceMaps: false, > ``` --- ## 5. Blockchain Read Optimization (High) ### B1 — ERC721Enumerable Scanning Is Unused 🔎 **Location:** `app/dapp/page.tsx:492-513` > [!warning] Impact > Code detects `ERC721Enumerable` support but never actually uses `tokenOfOwnerByIndex` to fetch token IDs. **Problem:** ```typescript useEffect(() => { const fetchTokens = async () => { if (!effectiveWallet || !balance || !canEnumerate) return; const count = Number(balance); const max = Math.min(count, 10); const reads = Array.from({ length: max }, (_, i) => ({ abi: erc721Abi, address: nftAddress, functionName: 'tokenOfOwnerByIndex', args: [effectiveWallet, BigInt(i)], })); // ... reads are built but NEVER EXECUTED }; fetchTokens(); }, [effectiveWallet, balance, canEnumerate, nftAddress, selectedChainId]); ``` The `reads` array is built but never passed to `useReadContracts` or `publicClient.multicall`. This is dead code that runs useless logic on every balance change. > [!tip] Remediation > Either implement the multicall or remove this effect entirely. --- ### B2 — Manual `ownerOf` Scanning Is Inefficient 🔎 **Location:** `app/dapp/page.tsx:318-414` > [!warning] Impact > Browser scans 12 tokens at a time via individual RPC calls. For a wallet with 50 NFTs, this is 50+ sequential RPC round-trips from the browser. **Problem:** The frontend calls `publicClient.readContract` in a loop with `await sleep(50)` between each. > [!tip] Remediation > 1. **Prefer nftcache API** — it's already implemented and batch-scans server-side > 2. If client-side scanning is needed, use `publicClient.multicall`: > ```typescript > const results = await publicClient.multicall({ > contracts: indices.map(i => ({ > abi: erc721Abi, > address: nftAddress, > functionName: 'ownerOf', > args: [BigInt(i)], > })), > }); > ``` > > This sends all 12 `ownerOf` calls in a single HTTP request. --- ## Remediation Priority Matrix | Priority | Issue | Effort | Impact | |----------|-------|--------|--------| | **P0** | F3 — Batch 14 `useReadContract` into 1 | 4h | Massive reduction in RPC calls and re-renders | | **P0** | N1 — Batch RPC scanning in nftcache | 4h | 10-50x faster contract scanning | | **P1** | F1 — Split dapp page into components | 1d | Eliminates full-page re-renders | | **P1** | F5 — Add `next/dynamic` for SettingsModal | 30m | Faster initial page load | | **P1** | S1 — Use `mget` in `listDueTasks` | 1h | 25x fewer Redis round-trips | | **P1** | S2 — Parallelize cron task execution | 1h | Prevents Vercel timeout | | **P2** | F2 — Stabilize `useReadContracts` deps | 2h | Reduces unnecessary refetching | | **P2** | F4 — Fix auto-reschedule effect | 1h | Prevents job thrashing | | **P2** | N4 — Deduplicate background refreshes | 2h | Prevents memory/RPC exhaustion | | **P2** | B2 — Use `multicall` for client scan | 2h | 12x faster NFT discovery | | **P3** | F7 — Optimize bundle size | 4h | Faster time-to-interactive | | **P3** | D1/D2 — Fix Dockerfile + .dockerignore | 1h | Faster builds, smaller images | --- ## Quick Wins (Apply Today) ### 1. Enable RPC Batching in wagmi ```typescript // config/web3.ts http(baseRpc, { batch: true, retryCount: 2 }) ``` Already set — verify your RPC provider supports `eth_call` batching. ### 2. Remove Dead Code ```typescript // app/dapp/page.tsx ~ lines 492-513 // Delete the unused ERC721Enumerable fetchTokens effect ``` ### 3. Add `.dockerignore` ``` .next/cache node_modules .git .env* *.md ``` ### 4. Use nftcache by Default Instead of client-side `ownerOf` scanning, default to the nftcache API which does the heavy lifting server-side. --- ## Related - [[Home]] - [[Architecture]] - [[Security Audit]] - [[Project Audit 2026-06]]