UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin of navbar, connect button, dapp page, loan cards, settings modal, and readme. Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched. Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]], callouts, Home MOC, folders Architecture/Operations/Audits) and added a full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy key value from the security audit example (rotate it at Schedy). Also commits the previously-untracked server layer: app/api (cron + tasks routes) and lib (redis, ssrf-guard, task-store). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
20 KiB
title, tags, type, status, updated
| title | tags | type | status | updated | |||
|---|---|---|---|---|---|---|---|
| Performance Audit |
|
audit | reference | 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 instancesuseEffect: 15 instancesuseMemo: 13 instancesuseCallback: 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:
// This function is recreated on EVERY render
const setPaymentAmount = (tokenId: bigint, value: string) => {
setPayInputs((prev) => ({ ...prev, [tokenId.toString()]: value }));
};
[!tip] Remediation
- Extract sub-components — Split into
PositionCard,PaymentControls,ScanPanel,AlertToggle- Memoize callbacks:
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]);
- 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:
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
// 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
useReadContractscall instead of mixinguseReadContract+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:
supportsInterfacebalanceOfcalculateAPRstablecoincontractCoindecimals(stable)symbol(stable)decimals(collateral)symbol(collateral)decimals(debt token)symbol(debt token)balanceOf(debt token)balanceOf(collateral)balanceOf(stable)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: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:
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
- Use a stable hash or deep-equality check instead of string serialization
- Add a mutex/ref to prevent concurrent scheduling:
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/dynamicimports anywhere - No
React.lazyorSuspense SettingsModal(~350 lines) is bundled into the initial chunk despite being rarely usedframer-motionis imported but may not be used on first paint
[!tip] Remediation
import dynamic from 'next/dynamic'; const SettingsModal = dynamic(() => import('@/components/SettingsModal'), { loading: () => <div className="animate-pulse">Loading...</div>, });Also lazy-load heavy Web3 libraries if possible:
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:
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:function useLocalStorage<T>(key: string, initial: T) { const [value, setValue] = useState<T>(() => { 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
- Enable tree-shaking for
viemby importing only needed modules:import { parseUnits } from 'viem'; // ✅ good import { Abi } from 'viem'; // ✅ good- Replace
@web3modal/wagmiwith the newer@reown/appkit(smaller bundle)- Remove unused dependencies (
@web3-react/core,@web3-react/injected-connectorif WalletConnect covers all wallets)- Use
next/dynamicfor 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:
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:
[
{"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:
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:
http: &http.Client{}, // default — no connection reuse tuning
[!tip] Remediation
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:
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_calls. 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:
if age >= a.ttl {
go func(net, contract string, maxTokenId int, key string) {
// ... full rescan
}(net, cContract, maxTokenId, contractCacheKey)
}
[!tip] Remediation Use
sync.SingleFlightto deduplicate: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:
export async function listDueTasks(before: number): Promise<Task[]> {
const ids = await r.zrange<string[]>(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
mgetfor batch retrieval:export async function listDueTasks(before: number): Promise<Task[]> { const ids = await r.zrange<string[]>(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<string[]>(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.alland a concurrency limit: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:
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.
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:
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
- Add
.dockerignoreto exclude.next/cache,node_modules,.git:.next/cache node_modules .git .env*- Disable source maps in production:
// 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
ERC721Enumerablesupport but never actually usestokenOfOwnerByIndexto fetch token IDs.
Problem:
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
- Prefer nftcache API — it's already implemented and batch-scans server-side
- If client-side scanning is needed, use
publicClient.multicall:const results = await publicClient.multicall({ contracts: indices.map(i => ({ abi: erc721Abi, address: nftAddress, functionName: 'ownerOf', args: [BigInt(i)], })), });This sends all 12
ownerOfcalls 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
// config/web3.ts
http(baseRpc, { batch: true, retryCount: 2 })
Already set — verify your RPC provider supports eth_call batching.
2. Remove Dead Code
// 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.