Files
5chan/docs/agent-runs/codebase-audit-2026-04-23/06-performance.md
T
Tommaso CasaburiandGitHub 5dc5408a15 fix(codebase audit): preserve cleanup without regressions
Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
2026-04-24 15:48:07 +07:00

29 KiB
Raw Blame History

Performance Audit

Summary

5chan is a hash-routed SPA whose hot paths are feed views (Board, Catalog), thread views (Post + PostDesktop/PostMobile), and popular threads on the home page. The repo already uses react-virtuoso for the three large list surfaces, has a Pretext-driven itemSize estimator, has calibrated heights for preview cards, has manualChunks for vendors, has memoizee-cached getLinkMediaInfo, and has React.memo on the three leaf list items (Post, CatalogPost, CatalogRow, CommentMedia, PopularThreadCard). babel-plugin-react-compiler is wired into the Vite pipeline, so many inline-allocation issues will be auto-compiled out — but several bugs below sit in places the compiler cannot legally fix (bail-outs across module boundaries, regex compilation inside called utilities, DOM-attached listeners, store subscriptions).

The largest remaining wins are:

  1. a single useWindowWidth that re-renders 20+ components on every resize event without throttling,
  2. matchesPattern (and catalog filter color mapping) which compiles RegExps per comment per enabled filter on every render,
  3. feed <img> elements that never set loading="lazy", decoding="async", or explicit width/height, and
  4. Intl.DateTimeFormat constructed per post per render inside getFormattedDate.

None of the findings below revisit material already closed out by the prior audit runs (summarised next).

Prior Work

Three prior runs already triaged the hottest rerender/jank issues and their fixes are present on master:

  • docs/agent-runs/popular-threads-rerenders/ — detached the Popular Threads box from feed-state and community subscriptions once the popular-post cache is revealed, and disabled useCurrentTime timer updates for frozen consumers. The current use-popular-posts.ts confirms this: it calls useCurrentTime(cacheEntry.revealed ? false : 5) and freezes cacheEntry.posts once revealed.
  • docs/agent-runs/mobile-virtuoso-scroll-jank/ — raised the mobile multiboard defaultItemHeight and reverse-scroll viewport buffer, made the board scroll-state listener passive and pagehide-triggered (no longer fires on every scroll tick), and removed duplicate GIF first-frame work in CommentMedia. All three changes are visible in src/views/board/board.tsx (the pagehide save + minOverscanItemCount) and src/components/comment-media/comment-media.tsx (single useFetchGifFirstFrame call).
  • docs/agent-runs/pretext-feed-sizing/ — introduced Pretext height estimates for catalog/feed/reply surfaces, split preview-reply from thread-reply calibrations, disabled content-visibility:auto on virtualized reply roots under the item-size path, compressed the itemSize lookup, and — critically — fixed the itemSize={undefined} footgun by only passing the itemSize prop when the explicit item-size mode is active (src/views/board/board.tsx L375, src/views/catalog/catalog.tsx L735). Don't re-report this pitfall; it's now inline-commented in both files.

Findings below avoid these closed surfaces except where a new sibling issue sits next to them.

Findings

Critical

  • src/hooks/use-window-width.ts:1-18useWindowWidth() puts its own resize listener on window and calls setWindowWidth(window.innerWidth) on every single resize event with no requestAnimationFrame/debounce/throttle. useIsMobile is a thin wrapper around it, and 22 components/hooks call useIsMobile() (plus Catalog calls useWindowWidth directly for columnCount). During a window resize every one of those hook instances commits a state update per frame, which cascades into Catalog/Board/PostDesktop/PostMobile renders and blows through the 16ms budget. Each consumer also installs its own listener, so each resize fires N listeners where N = mounted consumers. Fix: move to a single module-level resize listener feeding a Zustand store (or a single useSyncExternalStore hook with a ResizeObserver attached to document.documentElement), throttle via requestAnimationFrame, and snap to a coarse breakpoint token ('mobile' | 'desktop') so useIsMobile only re-renders when the breakpoint actually crosses 640px instead of on every pixel change.

  • src/lib/utils/pattern-utils.ts:30-92matchesPattern compiles a new RegExp(\\b${…}\b`, 'i')on every call — up to ~5 regexes per call in the OR/AND/wildcard branches. It's called per-comment per-enabled-filter from Catalog'screateCombinedFilter, processedFeed(L649 — top filter), andmatchedFilterColors(L674-695 — colored filters over the entirecatalogRenderFeed). With even a couple of enabled filters on a 500-post catalog that is thousands of regex compilations per Catalog render. *Fix:* cache compiled regexes in a module-level Map<string, RegExp>keyed by${pattern}\0${flags}, and/or precompile per FilterItemwhen filters change and attach the compiled regexes to the filter item.matchesPattern` should accept a prepared filter object, not the raw pattern string.

  • src/views/catalog/catalog.tsx:674-695matchedFilterColors iterates the full catalogRenderFeed and calls commentMatchesPattern for each colored filter on every render in which catalogRenderFeed or filterItems changes. The Map is also returned fresh each render and threaded into CatalogRow via renderCatalogRow, which flows into every CatalogPost. Combined with the regex-compile issue above, this is the single hottest catalog path. Fix: precompile filter regexes (see item 2), and replace the linear scan with a prepass keyed on cid that is only re-run when filterItems changes (use the feed length and the last cid as a cheap invalidation token, or move the matched-color assignment into the already-memoized rows pass so the Map is built once per feed mutation rather than once per render).

High

  • src/lib/utils/time-utils.ts:3-35getFormattedDate constructs a brand-new new Intl.DateTimeFormat(locale, { … }) on every call. There are 29 getFormattedDate/getFormattedTimeAgo call sites across posts, replies, tooltips, archive, and mod queue; a thread with 200 visible replies re-instantiates Intl.DateTimeFormat 200 times per render just for the timestamp strings. Intl.DateTimeFormat construction is measurably expensive (sub-ms but never free; it allocates an ICU formatter). Fix: module-scope a Map<string, Intl.DateTimeFormat> keyed by locale and memoize; invalidate on i18next.on('languageChanged'). The .format(new Date(ts*1000)) call is fine to keep.

  • src/views/catalog/catalog.tsx:532-584 and 585-634 — The component builds two near-identical footer memo objects, footerComponents and catalogFooter, from the exact same 14-element dep array. Both trees call <CatalogFooter>, <PageFooterDesktop>, and <PageFooterMobile> with the same props. Even with the deps aligned, the deps include location.search and cappedFeed.length, so the memo churns on every feed append and every query-string change, re-minting the components={{Footer: () => …}} identity and forcing Virtuoso to swap its footer component on scroll-triggered loads. Fix: keep a single memoized footer (drop catalogFooter, render footerComponents.Footer() in the non-virtualized branch), and split the footer into stable leaf components so that CatalogFooter, CatalogFooterFirstRow, and the mobile footer receive only the exact subset of props they depend on. The 14-dep list should shrink to three or four once the child components are split.

  • src/views/catalog/catalog.tsx:275,309,328 and src/views/home/popular-threads-box/popular-threads-box.tsx:69 — Whole-store destructuring const { filterItems, searchText } = useCatalogFiltersStore();, const { imageSize, showOPComment } = useCatalogStyleStore();, const { sortType } = useSortingStore();, const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();. Zustand returns the entire state when called with no selector, so Catalog re-renders on any field change in any of those three stores — including filteredCount, filteredCids, matchedFilters, currentCommunityAddress, filterText, etc., which incrementFilterCount mutates as the feed loads. Catalog re-rendering in turn re-runs the per-comment processedFeed/matchedFilterColors loops. Fix: replace each destructure with atomic selectors, e.g. useCatalogFiltersStore(s => s.filterItems) and useCatalogFiltersStore(s => s.searchText). Same pattern elsewhere (mod-queue.tsx:845, boards-bar.tsx:100-103, board-buttons.tsx:248,268,285,358,374,425,464,630).

  • src/components/comment-media/comment-media.tsx:144const linkWithoutThumbnail = url && new URL(url); runs inside the Thumbnail render body. new URL(…) allocates and parses; it executes per reply media per render (and throws on malformed URLs, which is caught by the surrounding flow only because linkWithoutThumbnail is then checked for truthiness — malformed urls will throw). Fix: wrap in the safeParseUrl helper that markdown.tsx already defines and call it outside the render path (memoize on url). fallbackLinkLabel should be computed from the memoized URL.

  • src/components/post-desktop/post-desktop.tsx:632 and :662 — Inside PostMedia: const embedUrl = url && new URL(url); (unconditional per render) and const filename = new URL(url).pathname.split('/').pop(); inside an IIFE that runs every render. Every feed card with a link reconstructs URL twice per commit. Fix: move both behind a module-scope safeParseUrl memoized by url, or compute once in a useMemo.

  • src/components/comment-media/comment-media.tsx & src/components/catalog-row/catalog-row.tsx & src/components/post-desktop/post-desktop.tsx & src/components/post-mobile/post-mobile.tsxNo <img> in the entire feed path sets loading="lazy", decoding="async", or fetchpriority. I grepped the whole src/ tree — zero matches. In Catalog this means every thumbnail in the initial column grid starts decoding on the main thread at mount (Chrome's loading="lazy" default for non-viewport images would cut this dramatically). Board cards behind Virtuoso still mount for overscan + viewport buffer (increaseViewportBy={bottom: 2400, top: 1200} on desktop), so 30-40 above-the-fold images decode synchronously on first feed load. This is a direct LCP/INP hit. Fix: on every <img> in comment-media.tsx, catalog-row.tsx (CatalogPostMedia), post-desktop.tsx, post-mobile.tsx, add loading="lazy" and decoding="async" (except the first card's thumbnail, which should be eager to preserve LCP). Also forward width/height attrs to the thumbnail <img> in Thumbnail (the wrapper already computes displayWidth/displayHeight but only writes them as CSS vars) so the browser can reserve layout and skip a CLS bounce.

  • src/components/comment-media/comment-media.tsx:297-408Image renders the expanded <img> without loading="lazy" or decoding="async" and without width/height attrs when expanded. The entire thumbnail scaffold writes --width/--height CSS vars but never a numeric width/height on the element; browsers can't reserve space, so every image load is a guaranteed CLS increment. CatalogPostMedia at catalog-row.tsx:84-99 already computes numericWidth/numericHeight for this reason — the rest of the codebase should follow. Fix: compute numeric pixel dimensions once and pass them as real width/height attributes (plus CSS for the stylistic scaling).

  • src/views/board/board.tsx:480-492 — In mobile PageFooterMobile, Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => …) allocates a new array on every render and emits a <Link> per page. For popular boards maxGuiPages can be 15, so it's a small allocation, but the footer is part of footerComponents whose dep list is 14 items; many of those deps change during scroll-triggered loads. Fix: either hoist this page list into a memo keyed by totalPages, or render a compact page selector rather than enumerating all pages.

Medium

  • src/components/feed-cache-container/feed-cache-container.tsx:71const { cachedFeeds, accessFeed } = useFeedCacheStore(); subscribes to the entire feed cache store. When the store mutates lastAccessed inside accessFeed (which it does every time a new feed route is visited, see store L23-31), the container re-renders and re-emits all cached feed wrappers. Each wrapper holds a <Catalog> or <Board>, which then re-evaluates its entire prop list through the visibility CSS trick. Fix: useFeedCacheStore(s => s.cachedFeeds, shallow) and grab accessFeed via useFeedCacheStore.getState() inside the effect. Better: split the store so cachedFeeds lives in its own atom and accessFeed is a plain exported function.

  • src/components/markdown/markdown.tsx:184tokenize() calls new RegExp(COMBINED_REGEX.source, 'g') per invocation. It's called once per non-empty line per post render inside the memoized rendered, so it's shielded for stable posts, but for any post whose content changes the rebuild cost is O(lines × RegExp-compile). Fix: tokenize can reuse a module-scoped RegExp and regex.lastIndex = 0 between lines; the current allocation per line is unnecessary because g regex state is local to exec loops that finish before the next line starts.

  • src/lib/utils/pattern-utils.ts:213-268commentMatchesPattern rebuilds titleLower + ' ' + contentLower (two toLowerCase calls per comment per call) every time. With three to five enabled filters over 500 comments per render, that's 2000+ toLowerCase allocations on the main thread. Fix: cache the lowercase concatenation on the comment object (WeakMap keyed by comment reference) or compute once in the memoized feed loop and pass the precomputed string to a lower-level matcher.

  • src/components/post-desktop/post-desktop.tsx:248, src/components/post-mobile/post-mobile.tsx:103const currentTime = useCurrentTime(); runs in every visible PostDesktop/PostMobile, each starting its own setInterval(…, 60_000). Virtuoso keeps 10-30 mounted rows, so that's 10-30 intervals, each fanning out a state update that re-renders the whole post every minute (and currentTime is only used if isInModQueueView — otherwise the value is read but unused). For feed posts outside the mod queue the entire interval + state is dead weight. Fix: pass useCurrentTime(false) (or omit the call) when !isInModQueueView, or expose a singleton current-time store (one interval, N subscribers with granular selectors).

  • src/components/boards-bar/boards-bar.tsx:370-385 — The mobile navbar hide-on-scroll uses debounce(..., 50ms) from lodash but then calls setVisible(...) on every debounced tick, re-rendering BoardsBarMobile. Debounce of 50ms is effectively no debounce at 60 fps (~3 frames). Fix: switch to requestAnimationFrame coalescing (track scrollY in a ref, schedule a single rAF if not pending, compare direction against last recorded scrollY) and only setVisible when the direction flips, not on every sample.

  • src/views/catalog/catalog.tsx:331const themeKey = typeof document !== 'undefined' ? document.body.className : ''; is read on every Catalog render and then used as a useMemo dep for catalogMetrics. It's not wrong (it tracks theme swaps) but it bypasses React's data flow and will miss theme changes unless Catalog re-renders for some other reason first. Fix: subscribe to theme via the existing use-theme-store / useTheme hook so the dependency is explicit and Catalog re-measures when the theme actually changes.

  • src/hooks/use-directories.ts:331-398useDirectories() sets component state in addition to a module-level cacheCommunities variable, and both useDirectories and useDirectoriesState mount their own fetchDirectoriesFromGitHubDeduped() effect. Every component that calls useDirectories (app.tsx, boards-bar, catalog, board, every post, reply-quote-preview, rules) installs its own hook state; a mount storm on first-paint means N×state-hydration for the shared dataset. inFlightGitHubFetch dedupes the network call but not the hook state. Fix: hoist the directory cache into a tiny Zustand store with a single initialization effect guarded by a module-level boolean; consumers read a stable reference.

  • src/views/catalog/catalog.tsx:459 / src/views/board/board.tsx:320const feedCids = useMemo(() => new Set(feed.map(f => f.cid)), [feed]) allocates a Set plus a string array of O(N) on every feed mutation. Fine for N=100, expensive in multiboard mode where feed can be 500+ entries and mutations are frequent. Fix: maintain the Set incrementally inside the store that produces feed, or fall back to a linear scan when the number of recentAccountComments is 0 (the common case) — right now you build the full Set even when there's nothing to test against.

  • src/components/catalog-row/catalog-row.tsx:158-188CatalogPost configures a Floating UI floating with a middleware array containing a size({ apply }) that reads window.innerWidth and getBoundingClientRect() on every resize. With N=500 catalog cards all registering useFloating, every catalog card installs a Floating UI autoUpdate listener. Fix: only call useFloating when showPortal is true (or mount a single shared portal at the Catalog level and pass per-card reference through context), so the setup is deferred until hover.

  • src/hooks/use-fetch-gif-first-frame.ts:85-138 — Each useFetchGifFirstFrame call holds its own state (IDLE/LOADING/READY/FAILED) and, on first mount with a cache miss, runs parseGif which draws into a canvas. For multiple visible GIFs, each runs its own canvas decode in parallel. The 2026-03-31 audit removed the duplicate call inside CommentMedia, but the cross-component duplication remains: comment-media.tsx:428 and post-desktop.tsx:623 both call the hook with the same URL for the same post. Fix: consolidate to a single call at the CommentMedia layer and pass the gifFrameState down via props (already the pattern inside comment-media.tsx between CommentMedia and Thumbnail, it just isn't respected across the post shell).

  • src/components/catalog-row/catalog-row.tsx:37-116CatalogPostMedia is not wrapped in memo, yet it's the per-thumbnail leaf in the catalog grid. Its only internal state is isLoaded/hasError, and its props are all primitives (except commentMediaInfo, which is memoized upstream via useCommentMediaInfo). Without memo, when the parent CatalogPost re-renders (e.g. when matchedFilterColor flips), every CatalogPostMedia re-renders and re-creates inline loadingStyle/CSSProperties objects. Fix: export default memo(CatalogPostMedia) with a shallow prop equality check.

  • src/views/catalog/catalog.tsx:697-717 — The rows memo caches individual rows by cid-concat key but compares arrays element-wise inside the same pass every time. For a 200-thread catalog that's 200 key builds + 200 Map lookups + 200 row comparisons per feed change. This is fine in isolation, but it also triggers on columnCount or isFeedLoaded changes, which means a single window resize through useWindowWidth repaints the entire cache. Fix: debounce columnCount to the nearest column-width breakpoint (e.g. bucket windowWidth / columnWidth with a 50-pixel hysteresis) so a continuous drag doesn't repaint the grid 30 times per second.

Low

  • src/hooks/use-current-time.ts:20-23 — Every setInterval tick calls setCurrentTime(Date.now() / 1000) even when the value would round to the same second for many consumers. Low-impact because React.memo filters downstream, but still forces a commit in every subscriber. Fix: only setCurrentTime if Math.floor(newTime) !== Math.floor(prev).

  • src/views/home/home.tsx:87-132Stats receives directoryAddresses as a prop and iterates through the full list in a memo, but also emits <CommunityStatsCollector key={address} communityAddress={address} /> for every directory board. Each collector mounts its own useCommunity({ community: communityIdentifier }) hook, so the home page fetches community metadata for every tracked board concurrently. Fix: gate the collectors behind visibility (IntersectionObserver) or throttle to the first N on mount and load the rest incrementally.

  • src/components/post-desktop/post-desktop.tsx:452-467, :1151<img src='assets/icons/sticky.gif'/>, <img src='assets/icons/closed.gif'/>, <img src='assets/icons/archived.gif'/>, <img src='assets/xmashat.gif'/> — no width/height or loading="lazy". Same for mobile (post-mobile.tsx:834). Small cost per image but these tags render inside post-info near the LCP area. Fix: add intrinsic sizes; ideally inline as CSS background-image with fixed dimensions so they don't participate in the image-decoding queue at all.

  • src/components/boards-bar/boards-bar.tsx:109-119useAccountsStore(state => [...(activeAccount?.subscriptions || [])], equalityFn) rebuilds the subscriptions array on every store update and relies on a custom equalityFn. It works, but the shallow helper from zustand/shallow is cheaper and better documented. Low priority — the custom equality is correct.

  • src/lib/utils/pattern-utils.ts:23-35 — The regex-pattern detection uses /\/[gimsuy]*$/.test(pattern) on every call for every filter string, even ones the user never wrote as regex. Cheap but avoidable. Fix: detect regex once per filter item at filter-item-change time.

  • src/lib/snow.ts:85-102shouldShowSnow() constructs new Date() inside a helper called from several renders (posts, board header, archive, catalog row). For 500 catalog cards that's 500 new Date() allocations. Fix: cache the December-24/25 answer once per page load since the date can't change during a session.

  • src/views/board/board.tsx:540-541const boardViewportBuffer = isMultiboardView ? … : …; and const boardMinOverscanItemCount = …; are inline objects allocated every render and handed to Virtuoso's increaseViewportBy / minOverscanItemCount props. Virtuoso re-reads those props on each update; with React compiler off this would cause extra Virtuoso churn. With compiler on, it should be auto-memoized — but note the isMultiboardView && isMobile branch writes { bottom: 4, top: 8 } which is always allocated fresh. Fix: useMemo these two objects explicitly (belt and suspenders — some compiler heuristics bail on conditional returns).

  • src/stores/use-feed-cache-store.ts:20maxCacheSize: 2 means every navigation between three different feed routes mounts a fresh <Board>/<Catalog> (with all of its hooks) and evicts the least-recent. The current CSS trick in feed-cache-container.module.css hides the non-active feed but keeps it mounted; bumping to 3-4 would keep common back-nav flows warm without a mount cost. Fix: raise to 3 or 4 after verifying memory cost.

  • src/components/post-desktop/post-desktop.tsx:1231-1262 and :1235 — The thread-replies <Virtuoso> is passed a fresh inline itemContent={(index, reply) => …} and a fresh increaseViewportBy={{ bottom: 1200, top: 1200 }} object on every PostDesktop render. Virtuoso dedupes internally on prop identity, so fresh object identities force its internal memo cache to invalidate and re-render the mounted reply rows. Additionally, ref={(element) => reportReplyHeightAuditSample(element, …, reply.cid)} is an inline ref callback which React calls twice (null then element) on every render for every mounted row — that's a lot of DOM-read audit work per scroll tick. Fix: useCallback the itemContent factory, hoist increaseViewportBy into a module constant (or useMemo), and guard the audit ref with import.meta.env.DEV so production scroll is audit-free. Same pattern likely in post-mobile.tsx.

  • src/components/post-desktop/post-desktop.tsx:1283-1302 — Board-view preview replies render non-virtualized: .map((reply, index) => <div …><Reply … /></div>). That's fine for 3-5 preview replies per card, but the <Reply> inner tree still does a full render including its own useReplies call (inside the downstream components). Every PostDesktop re-render also re-renders all previews unless Reply is itself memoed. Fix: verify Reply is wrapped in memo with a cid-based equality check; if not, wrap it. The Reply props include quotedByMap and directRepliesByParentCid which come from useMemo in the parent — make sure those memos are stable across unrelated parent rerenders.

  • src/hooks/use-count-links-in-replies.ts:5-18useCountLinksInReplies runs a linear scan over every reply on every call, and its loop runs outside the useMemo (only flattenedReplies is memoized). The for loop and linkCount allocation happens every render. It's called from CatalogPost (catalog-row.tsx:126), PostDesktop (via PostInfo), and PostMobile. For a thread OP with 500 replies the count re-runs on every memo miss. Fix: wrap the count itself in useMemo([flattenedReplies, firstXReplies]) so only reply-list changes re-count.

  • src/components/catalog-row/catalog-row.tsx:360-390CatalogRow has a custom memo equality that includes a for-loop over row.length comparing element identity and matched color. That's correct, but it's O(N) per candidate rerender, and N=columns (up to 6 on wide monitors). With 200 rows that's 1200 comparisons per Catalog render. Not a hotspot on its own, but combined with the matchedFilterColors churn it amplifies. Fix: matchedFilterColors should be the same Map identity across renders when no filter actually changed (fix the upstream memo), at which point the equality check short-circuits.

  • src/components/catalog-row/catalog-row.tsx:46,74-79,107-110CatalogPostMedia allocates loadingStyle = { opacity: isLoaded ? 1 : 0 }, CSSProperties = {...}, and the merged style={{ ...CSSProperties, ...(matchedFilterColor ? { border: ... } : {}) }} on every render. React will see a new style object identity and diff styles per commit. Fix: memoize; ideally migrate to a CSS custom-property approach so JS doesn't allocate style objects per card at all.

  • src/components/board-header/board-header.tsx:72const subscriptionsCount = useAccountsStore((state) => { …complex selector… }) — I didn't read the full selector but this needs an equality function or a shallow-friendly return, otherwise any account store change causes a header rerender. Fix: ensure the selector returns a number primitive and doesn't allocate a new object.

  • src/components/comment-media/comment-media.tsx:505-522memo(CommentMedia, (prev, next) => { … 14 identity checks … }) — 14 comparisons on every sibling rerender is fine, but several of the compared props are bound via setShowThumbnail which, in the caller, may be a fresh useState setter (it's stable, actually, so this is OK). I note it to flag: if callers ever migrate setShowThumbnail to a wrapped callback, this memo will silently degrade. Fix: consider switching to the simpler default shallow memo once you're confident all callers pass stable references.

  • src/hooks/use-directories.ts:276-297getFromLocalStorage calls localStorage.getItem twice plus JSON.parse on the cached string on every mount of a useDirectories consumer that hasn't yet populated the module-level cache. On a cold tab load this happens once per consumer until the module cache fills, but the read is synchronous on the main thread during React's commit phase. Fix: do the localStorage read before useState is called (module load time) and pass the result as the initial state; the effect then only refreshes against GitHub.

Top 5 Actions

  1. Rewrite useWindowWidth as a single rAF-throttled, breakpoint-bucketed global subscription (and refactor useIsMobile to a coarse 'mobile' | 'desktop' selector). This cuts commits during resize from O(N consumers × resize events) to O(breakpoint flips), and removes a top-10 hotspot that shows up under DevTools Performance as a flurry of useWindowWidth state updates.

  2. Memoize pattern regexes and lowercase comment fields. Module-scope Map<string, RegExp>, compile per filter-item mutation, and precompute ${title} ${content}.toLowerCase() per comment (WeakMap). Rewrite matchedFilterColors in catalog.tsx to a single pass keyed by the memoized pattern list. Expected: massive reduction in Catalog render cost with any filter active.

  3. Add loading="lazy" + decoding="async" + explicit numeric width/height on every feed <img> (comment-media.tsx, catalog-row.tsx, post-desktop.tsx, post-mobile.tsx, home.tsx logo, decoration GIFs). Direct LCP/CLS improvement; zero behavioural risk.

  4. Module-scope Intl.DateTimeFormat cache in time-utils.ts keyed by locale, invalidated on i18next language change. Cuts ~N×ICU-formatter constructions per render on any page that shows more than a handful of timestamps (threads, mod queue, archive).

  5. Kill whole-store destructures and the catalog double-footer memo. Switch Catalog, PopularThreadsBox, ModQueue, BoardsBar, and board-buttons to per-field Zustand selectors; collapse footerComponents and catalogFooter into one memo so the Virtuoso components identity only changes when a footer-visible field changes. Combined with (2) this removes the single largest remaining source of "feed rerenders on unrelated store mutations" after the prior popular-threads fix.