Ask
214
@lowtech_lior ·

next 15 upgrade turned every fetch uncached and my pricing api bill went 16x Caching

Upgraded from 14.2.15 to 15.x over a weekend. No code changes to the route, no errors, build passed, lighthouse fine. Six days later the upstream vendor emails me because I went from about 5,760 calls a day to 96,000.

The page is app/p/[slug]/page.tsx, four await fetch() calls to a pricing API, and I had assumed for a year that fetch was cached by default and that my revalidate = 60 was doing something. Apparently not any more.

What is the actual current way to say "cache this for a minute"? And is there anything in the build output that would have told me before the invoice did?

9 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @litercount_lou · 3h ago · 3 replies

    The default flipped. In 14 an un-annotated fetch() was cached; in 15 it is no-store unless you opt in. Same for GET route handlers, which used to be static and now are not.

    Two ways to opt back in, and they compose:

    export const revalidate = 60          // segment level
    fetch(url, { next: { revalidate: 60 } })  // per call
    

    And yes, the build output would have told you. The legend at the end of next build marks each route static or dynamic. If your product page moved from the static marker to the dynamic one between two builds, that is the 16x, printed in your terminal, for free. Diff that output in CI and you catch this class of regression permanently.

    47
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @lowtech_lior · 3h ago

      Confirmed, the route went dynamic in the build output and I never looked. Segment-level revalidate put me back to 5,760/day. Feels bad that the only signal was a legend I had trained myself to scroll past.

      12
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @awkward_ash · 3h ago

      Worth adding: if anything in that segment reads cookies or headers, the segment goes dynamic regardless of what you put on the fetch, and then your revalidate is quietly meaningless. Check for a cookies() call in a layout above the page before you conclude the fetch config is wrong.

      9
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @awkward_ash · 3h ago

    For anything that is not literally a fetch - a database query, an SDK call, an expensive computation - the fetch-level options do nothing for you. Wrap it:

    const getPricing = unstable_cache(
      async (slug) => db.pricing.find(slug),
      ['pricing'],
      { revalidate: 60, tags: ['pricing'] }
    )
    

    The tag is the part people skip and then regret, because revalidateTag('pricing') from your webhook is much nicer than waiting out a timer.

    31
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @wrangler_dev_jo · 8h ago

    Separate from fixing the config: if that vendor charges per call, do not let a framework cache be the only thing between you and the invoice. Stick a small cache in front that you own - KV, Redis, a table, anything with an explicit TTL you can read at 3am. Framework caching defaults are going to keep changing across majors and your vendor contract is not.

    18
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @arrayformula_al · 3h ago

    Put a counter on it. I patch global.fetch in a test that renders the route and assert the number of upstream calls per render is what I think it is. Took twenty minutes to write, has caught two of these, one of which was a dependency's SDK deciding to refresh a token on every request.

    Billing regressions are the only regressions where nobody files a bug for six weeks.

    22
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @keg_line_ken · 3h ago · 2 replies

    revalidate only takes effect if you also set export const dynamic = 'force-static', otherwise it is ignored. That is why yours did nothing.

    9
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @litercount_lou · 3h ago

      That is not right, and following it will break his page. revalidate works on its own; force-static additionally makes dynamic APIs return empty values, so if that route reads searchParams he will get a blank object and no error. Do not reach for force-static to fix caching, reach for it when you genuinely want the segment to never be dynamic.

      8
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @epoxy_puddle · 3h ago

    The build output legend is the most honest documentation this framework has and it is printed once, in grey, after 40 lines of nothing.

    7
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report