13 KiB
Scanner patterns
AST/grep-style scanners run in parallel with metric-driven investigation. They find known anti-patterns. Findings on cold-path or unmappable files are dropped unless the scanner declares trafficIndependent: true.
Total scanners: 15.
Patterns
cache-components-suspense-dedupe — 'use cache' with multiple Suspense boundaries on the same data
- Severity: medium
- Billing dimension: function-duration
- Traffic-independent: no (cold-path findings get dropped)
Description. Default 'use cache' does not dedupe identical calls across separate <Suspense> boundaries on the same render. Each boundary re-invokes the cached function, multiplying function-duration cost and inflating ISR write churn when the output is large.
Fix. Hoist the promise to the page level (const dataPromise = fetchData() at the top, passed down to each Suspense child) OR move the shared fetch into a 'use cache: remote' data-access layer so cross-request and cross-boundary dedupe applies.
Citations:
https://nextjs.org/docs/app/api-reference/directives/use-cachehttps://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponentshttps://nextjs.org/docs/app/guides/migrating-to-cache-components
edge-heavy-import — Heavy / node-only import inside edge-runtime file
- Severity: high
- Billing dimension: function-duration
- Traffic-independent: yes (cold-path findings survive the doctrine drop)
Description. Edge runtime is a constrained sandbox with no node: builtins and a much smaller cold-start budget than Node functions. Heavy SDKs (sharp, @aws-sdk/*, @prisma/client, pg, puppeteer) either fail at deploy or inflate cold-start latency. Move the import to a Node runtime function, or replace with an edge-compatible alternative (e.g., neon-driver instead of pg).
Fix. Either (a) drop the export const runtime = 'edge' so the route runs on Node (default in 2026), or (b) replace the heavy import with an edge-compatible alternative. For DB: use @neondatabase/serverless or @planetscale/database instead of pg/mysql2. For image: do the work in a Node route handler. For auth signing: use jose (Web Crypto) instead of jsonwebtoken.
Citations:
https://vercel.com/docs/functions/runtimes/edge-runtimehttps://vercel.com/docs/fluid-compute
force-dynamic — export const dynamic = 'force-dynamic'
- Severity: medium
- Billing dimension: function-duration
- Traffic-independent: no (cold-path findings get dropped)
Description. force-dynamic disables static + ISR rendering. The route runs the function on every request. Sometimes necessary (cookies, headers, real-time data), often a habit that costs function-duration and edge-requests at scale.
Fix. Audit the route. If dynamic behavior comes from cookies()/headers()/searchParams, force-dynamic may be redundant — Next infers dynamic automatically. Consider revalidate / 'use cache' / generateStaticParams if any portion can be pre-rendered.
Citations:
https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
headers-in-page — Dynamic API call forcing dynamic rendering
- Severity: medium
- Billing dimension: function-duration
- Traffic-independent: no (cold-path findings get dropped)
Description. headers(), cookies(), and draftMode() are dynamic APIs. Reading them in a page/layout makes the entire segment dynamic — no ISR, no static generation, and a function invocation on every request.
Fix. Move the dynamic API call into a child Server Component that lives inside a Suspense boundary. The parent can stay static; only the leaf re-renders dynamically.
Citations:
https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-confighttps://nextjs.org/docs/app/building-your-application/caching
large-static-asset — Large file in public/
- Severity: medium
- Billing dimension: bandwidth
- Traffic-independent: yes (cold-path findings survive the doctrine drop)
Description. Static assets in public/ over 500 KB ship as-is from the CDN. Whether the cost is meaningful depends on traffic, but the candidate is binary — the file is either needed at that size or it can be optimized (compressed image, video transcode, or moved off the critical path).
Fix. Verify the asset is reachable on the customer-facing hot path. Then choose: (a) compress (convert PNG → AVIF/WebP; transcode MP4 to lower bitrate); (b) host externally (Vercel Blob, S3, or a media CDN with per-asset signed URLs); (c) lazy-load (defer to client-side fetch instead of bundling into initial HTML).
Citations:
https://vercel.com/docs/manage-cdn-usagehttps://vercel.com/docs/image-optimization
max-age-without-s-maxage — Cache-Control: max-age without s-maxage
- Severity: medium
- Billing dimension: edge-requests
- Traffic-independent: no (cold-path findings get dropped)
Description. max-age caches in the browser; s-maxage caches at the CDN. Without s-maxage, every uncached visitor request invokes the function. Adding s-maxage often cuts function invocations by 80%+ on read-heavy routes.
Fix. Add s-maxage to the Cache-Control header. Example: Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400. Pair with explicit cache-bust strategy if content can change.
Citations:
https://vercel.com/docs/caching/cdn-cachehttps://vercel.com/docs/caching/cache-control-headers
middleware-broad-matcher — Middleware matcher missing or too broad
- Severity: high
- Billing dimension: edge-requests
- Traffic-independent: yes (cold-path findings survive the doctrine drop)
Description. middleware.ts without a config.matcher (or matcher: ["/(.*)"]) runs on every request including _next/static, _next/image, favicon.ico, and image asset fetches. Edge-request cost scales accordingly.
Fix. Scope the matcher to actual application paths. Example: matcher: ["/((?!_next/static|_next/image|favicon.ico|.\.(?:svg|png|jpg|jpeg|gif|webp)$).)"]
Citations:
https://nextjs.org/docs/app/building-your-application/routing/middleware
missing-cache-headers — Cacheable route or fetch with no caching (Cache-Control absent or no-store)
- Severity: medium
- Billing dimension: edge-requests
- Traffic-independent: no (cold-path findings get dropped)
Description. Two antipatterns: (a) GET handlers without explicit Cache-Control headers serve uncached; (b) fetch() calls with cache:"no-store" or next:{revalidate:0} opt out of caching even on cacheable upstream data. For non-auth routes / fetches, both are leaving cache hits on the floor.
Fix. For GET handlers: return a Response with Cache-Control: public, s-maxage=, stale-while-revalidate=. For fetch(): drop cache:"no-store" (use { next: { revalidate: } } in Next.js) so the response is cached by the framework + CDN.
Citations:
https://vercel.com/docs/caching/cdn-cachehttps://vercel.com/docs/caching/cache-control-headershttps://nextjs.org/docs/app/building-your-application/caching
prisma-include-tree-bloat — Deep Prisma include tree (3+ levels)
- Severity: high
- Billing dimension: function-duration
- Traffic-independent: no (cold-path findings get dropped)
Description. Nested .include({ x: { include: { y: { include: { z: ... } } } } }) makes Prisma issue a single huge join that scales O(NMK). Function duration explodes, memory spikes, often causes timeouts.
Fix. Replace with explicit .findMany() calls or scoped .include() of only what the consumer reads. Consider Prisma.select() to project specific fields. For lists, batch with DataLoader patterns.
Citations:
vercel-react-best-practices:server-parallel-fetching
region-pin-in-config — Function region pinned in config
- Severity: low
- Billing dimension: function-duration
- Traffic-independent: yes (cold-path findings survive the doctrine drop)
Description. vercel.json regions or per-route preferredRegion is set. If the pinned region is far from the dominant user geo (or far from a data source) p95 TTFB suffers. This scanner provides the configured-region signal so the region-misconfig gate can recommend an audit.
Fix. Audit the pinned region against traffic geography (Speed Insights or Web Analytics by country) and data-source location. Consider multi-region if data lives in a fixed location and users are global; consider relocating if users are concentrated in one geography.
Citations:
https://vercel.com/docs/functions/configuring-functions/regionhttps://vercel.com/docs/functions/configuring-functions/region
source-maps-production — Source maps enabled in production
- Severity: low
- Billing dimension: edge-requests
- Traffic-independent: yes (cold-path findings survive the doctrine drop)
Description. productionBrowserSourceMaps: true ships .map files in the production bundle, increasing transfer size 30-100% per visitor. Useful for error reporting via Sentry; not useful for users.
Fix. Keep source maps generation but exclude them from the public bundle. Upload to your error tracker via build-time CI step; do not serve them with the deployment.
Citations:
https://nextjs.org/docs/messages/improper-devtool
sveltekit-prerender-missing — SvelteKit page without explicit prerender / ISR config
- Severity: low
- Billing dimension: function-duration
- Traffic-independent: no (cold-path findings get dropped)
Description. SvelteKit page or +page.server.ts is missing an explicit prerender, ssr, or adapter config.isr declaration. Default is per-request function execution — investigate whether the route could be prerendered or ISR-cached.
Fix. If the page is static (no per-user / per-request data), add export const prerender = true in +page.ts or +page.server.ts. If the data refreshes on a schedule, prefer adapter-vercel's ISR option via export const config = { isr: { expiration: 60 } }.
Citations:
https://kit.svelte.dev/docs/page-optionshttps://kit.svelte.dev/docs/adapter-vercelhttps://vercel.com/docs/incremental-static-regeneration
turbo-force-bypass — Turborepo cache bypass on a monorepo
- Severity: high
- Billing dimension: build
- Traffic-independent: yes (cold-path findings survive the doctrine drop)
Description. Turborepo's per-task cache can be bypassed by an explicit force flag, a cache: false config, or missing build-skip configuration. Every commit can rebuild unchanged work; Build Minutes climb with project count.
Fix. Remove TURBO_FORCE=true from build env/scripts unless intentional. Set tasks.build.cache: true in turbo.json (or remove the override), and include generated outputs in Turbo's cache contract. Prefer Vercel's skip-unaffected monorepo behavior when available; use ignoreCommand only when that setting cannot cover the project.
Citations:
https://vercel.com/docs/monoreposhttps://vercel.com/docs/buildshttps://turborepo.dev/docs/crafting-your-repository/caching
unoptimized-image — Image optimization gap (raw , global flag, missing sizes, or SVG mis-routed)
- Severity: high
- Billing dimension: image-optimization
- Traffic-independent: no (cold-path findings get dropped)
Description. Four shapes of image-cost waste: raw tags bypass the framework Image component; images.unoptimized: true disables Vercel image optimization globally; without sizes forces serving the largest source variant; without
unoptimized routes vector data through the raster pipeline.
Fix. For raw : switch to next/image, enhanced-img (SvelteKit), (Astro), or NuxtImg. For global unoptimized:true: remove the flag unless the project is hosted outside Vercel. For fill without sizes: add sizes="(max-width: 768px) 100vw, 50vw" or whatever matches your layout. For SVG: add unoptimized so the raw SVG ships instead of rastering it.
Citations:
https://nextjs.org/docs/app/api-reference/components/imagehttps://vercel.com/docs/image-optimization
use-cache-date-stamp — new Date() / Date.now() / Math.random() inside a 'use cache' file
- Severity: high
- Billing dimension: isr
- Traffic-independent: no (cold-path findings get dropped)
Description. 'use cache' memoizes by argument identity AND prerender output. A timestamp baked into the cached output (new Date().getFullYear() in a footer, Date.now() in a payload field) forces a fresh ISR write on every regeneration even when the underlying data is unchanged. Random values have the same failure mode.
Fix. Replace module-scope new Date() with a build-time constant (const buildYear = new Date().getFullYear()) or move per-request timestamps into a client component inside useEffect. Do not pass dates as arguments to 'use cache' functions — they invalidate the cache every call.
Citations:
https://nextjs.org/docs/app/api-reference/directives/use-cachehttps://nextjs.org/docs/app/api-reference/functions/cacheLife