Skip to content

perf: cache un-hashed static assets under /img and /logo - #475

Draft
alukach wants to merge 1 commit into
mainfrom
perf/static-asset-cache-headers
Draft

perf: cache un-hashed static assets under /img and /logo#475
alukach wants to merge 1 commit into
mainfrom
perf/static-asset-cache-headers

Conversation

@alukach

@alukach alukach commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Part of #471.

What

Adds a Cache-Control rule for /img/* and /logo/* in next.config.js, and makes headers() apply in prod (it previously returned no rules at all there). The noindex rule stays non-prod only.

public, max-age=3600, stale-while-revalidate=604800

Why

These assets are currently served public, max-age=0, must-revalidate, so every navigation pays a conditional-request round-trip for each one.

Calibrating the win honestly: I verified the revalidation returns 304 with 0 bytes, so this was never a re-download problem — just latency. It's a modest fix, not a headline one, and it's the smallest item in #471.

Why not immutable

The filenames under public/ are not content-hashed, so immutable would be actively dangerous — a redeploy could never replace them for anyone holding a cached copy. That's not hypothetical: #473 changes the bytes of clouds.png at the same filename.

An hour of freshness removes the round-trip while bounding how long a replaced asset can linger, and stale-while-revalidate keeps the refresh off the critical path. Content-hashing these filenames would unlock a real immutable policy later; noted in #471.

Verification

  • npx jest next.config.test.ts4 passed. I updated the three existing tests (the prod case asserted [], which no longer holds) and added one asserting the cache rule applies in every stage.

🤖 Generated with Claude Code

These are served from public/ with `public, max-age=0, must-revalidate`,
so every navigation pays a conditional-request round-trip for them. The
responses do return 304 with 0 bytes, so this was never a re-download
problem — just a latency one, which is why it's a modest win rather than
a large one.

`immutable` is not safe here: the filenames are not content-hashed, so a
redeploy has to be able to replace them in place. An hour of freshness
plus a week of stale-while-revalidate drops the round-trip while bounding
how long a replaced asset can linger.

Extends headers() to apply in prod, which previously returned no rules at
all; the noindex rule stays non-prod only. Tests updated to match.

Refs #471
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
source-cooperative Ready Ready Preview Aug 3, 2026 4:31pm

Request Review

@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 4m 38s —— View job


Review of PR #475

  • Gather context (PR diff, files)
  • Review next.config.js changes
  • Review next.config.test.ts changes
  • Post findings

❌ Changes requested — see findings below.

  • next.config.js:34 — The new source pattern /:dir(img|logo)/:path* doesn't just match static files under public/img and public/logo; it also matches the app's dynamic [account_id] (and [account_id]/[product_id]) route whenever the requested path doesn't resolve to an actual public file (e.g. a bare /img or /logo request, or a nonexistent nested path). Nothing in the codebase reserves "img" or "logo" as account ids — src/types/shared.ts's ID_REGEX/MIN_ID_LENGTH (3) would happily accept either as a username, and account creation (src/lib/actions/account.tsaccountsTable.create) does a plain PutCommand with no reserved-word check. Worse, that route is session-personalized: src/app/(app)/[account_id]/IndividualProfilePage.tsx:48,53 computes isOwner/canEdit per viewer from getPageSession(). If an account ever registers as img or logo, its profile page becomes reachable at that path and would pick up Cache-Control: public, max-age=3600, stale-while-revalidate=604800 — i.e. a shared/CDN cache could serve one viewer's session-flavored HTML (edit controls, owner state) to a different viewer for up to an hour. This is a real gap introduced by this diff, not pre-existing: the previous prod behavior returned no header rules at all for these paths.

    Two independent ways to close it:

    // Option A: reserve these ids so they can never collide with account/product routes
    // (mirrors the existing NON_ACCOUNT_SEGMENTS set in src/middleware.ts:68-78,
    // which doesn't currently include "img"/"logo" either)
    
    // Option B: scope the header rule to actual asset files instead of whole subtrees
    {
      source: "/:dir(img|logo)/:path*\\.(png|jpg|jpeg|svg|ico|gif|webp)",
      headers: [{ key: "Cache-Control", value: "public, max-age=3600, stale-while-revalidate=604800" }],
    }

Everything else checks out:

  • The rule correctly avoids immutable given un-hashed filenames, and stale-while-revalidate is a sound way to keep the refresh off the critical path.
  • No header-key collision between the new Cache-Control rule and the existing X-Robots-Tag noindex rule (different keys, order doesn't matter here).
  • next.config.test.ts accurately reflects the implementation's behavior for all three STAGE values.

Note: I wasn't able to run npx jest next.config.test.ts or git fetch in this sandbox (commands required approval that wasn't available), so the test-file analysis above is from manual inspection rather than an actual test run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant