Skip to content

feat(core): add X-Correlation-ID header, cache guest queries separately#2910

Closed
jorgemoya wants to merge 4 commits intocanaryfrom
catalyst-1787-unstable-cache-requests
Closed

feat(core): add X-Correlation-ID header, cache guest queries separately#2910
jorgemoya wants to merge 4 commits intocanaryfrom
catalyst-1787-unstable-cache-requests

Conversation

@jorgemoya
Copy link
Copy Markdown
Contributor

@jorgemoya jorgemoya commented Mar 6, 2026

What/Why?

All GraphQL requests now include an X-Correlation-ID header containing a UUID that is stable for the duration of a single page render (via React.cache), making it easier to trace and correlate all requests made during a single render in server logs.

Guest (unauthenticated) queries are now cached using unstable_cache with the configured revalidation interval, while authenticated requests continue to use cache: 'no-store'. This separates cacheable public data from session-specific data, improving performance for unauthenticated visitors. The X-Forwarded-For and True-Client-IP headers are only forwarded on uncached (no-store) requests since they are unavailable inside unstable_cache.

Testing

Locally + E2E

Screenshot 2026-03-09 at 12 31 31 PM

Migration

Step 1: Add the correlation ID helper

Create core/client/correlation-id.ts:

import { cache } from 'react';

/**
 * Returns a stable correlation ID for the current request.
 * React.cache ensures the same UUID is returned for all fetches within a
 * single page render, while being unique across renders/requests.
 */
export const getCorrelationId = cache((): string => crypto.randomUUID());

Step 2: Update core/client/index.ts

Update the beforeRequest hook to add the X-Correlation-ID header to all requests and to only forward X-Forwarded-For / True-Client-IP on uncached requests:

+ import { getCorrelationId } from './correlation-id';

  export const client = createClient({
    ...
    beforeRequest: async (fetchOptions) => {
      const requestHeaders: Record<string, string> = {};

-     try {
-       const ipAddress = (await headers()).get('X-Forwarded-For');
-       if (ipAddress) {
-         requestHeaders['X-Forwarded-For'] = ipAddress;
-         requestHeaders['True-Client-IP'] = ipAddress;
-       }
-     } catch {
-       // Not in a request context
-     }
+     if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) {
+       try {
+         // headers() is a dynamic API unavailable inside unstable_cache; skip IP forwarding in that context
+         const ipAddress = (await headers()).get('X-Forwarded-For');
+         if (ipAddress) {
+           requestHeaders['X-Forwarded-For'] = ipAddress;
+           requestHeaders['True-Client-IP'] = ipAddress;
+         }
+       } catch {
+         // Not in a request context (e.g. inside unstable_cache); IP forwarding not available
+       }
+     }
+
+     requestHeaders['X-Correlation-ID'] = getCorrelationId();

      return { headers: requestHeaders };
    },
  });

Step 3: Wrap guest queries with unstable_cache

For each page data file, wrap the guest (unauthenticated) fetch in unstable_cache and branch on whether a customerAccessToken is present. Example pattern:

+ import { unstable_cache } from 'next/cache';
  import { cache } from 'react';
+ import { revalidate } from '~/client/revalidate-target';

+ const getCachedPageData = unstable_cache(
+   async (locale: string, ...args) => {
+     const { data } = await client.fetch({
+       document: PageQuery,
+       variables: { ... },
+       locale,
+       fetchOptions: { cache: 'no-store' },
+     });
+     return data;
+   },
+   ['cache-key'],
+   { revalidate },
+ );

  export const getPageData = cache(
-   async (locale: string, customerAccessToken?: string) => {
-     const { data } = await client.fetch({
-       document: PageQuery,
-       locale,
-       fetchOptions: { cache: 'no-store' },
-     });
-     return data;
-   },
+   async (locale: string, customerAccessToken?: string) => {
+     if (customerAccessToken) {
+       const { data } = await client.fetch({
+         document: PageQuery,
+         customerAccessToken,
+         locale,
+         fetchOptions: { cache: 'no-store' },
+       });
+       return data;
+     }
+     return getCachedPageData(locale);
+   },
  );

Future path with Next 16

We will eventually migrate to use cacheComponents, so this migration to unstable_cache will provide as the foundation of caching request functions in Catalyst going forward. In a future scenario, it will be as simple as replacing unstable_cache for the use cache directive.

-const getCachedPageData = unstable_cache(
-  async (locale: string, currencyCode?: CurrencyCode) => {
-    const { data } = await client.fetch({
-      document: HomePageQuery,
-      variables: { currencyCode },
-      locale,
-      fetchOptions: { cache: 'no-store' },
-    });
-
-    return data;
-  },
-  ['get-page-data'],
-  { revalidate },
-);
-
-export const getPageData = cache(
-  async (locale: string, currencyCode?: CurrencyCode, customerAccessToken?: string) => {
-    if (customerAccessToken) {
-      const { data } = await client.fetch({
-        document: HomePageQuery,
-        customerAccessToken,
-        variables: { currencyCode },
-        locale,
-        fetchOptions: { cache: 'no-store' },
-      });
-
-      return data;
-    }
-
-    return getCachedPageData(locale, currencyCode);
-  },
-);
+async function getCachedPageData(locale: string, currencyCode?: CurrencyCode) {
+  'use cache';
+  cacheLife({revalidate});
+  cacheTag('page-data');
+
+  const { data } = await client.fetch({
+    document: HomePageQuery,
+    variables: { currencyCode },
+    locale,
+    fetchOptions: { cache: 'no-store' },
+  });
+
+  return data;
+}
+
+export async function getPageData(
+  locale: string,
+  currencyCode?: CurrencyCode,
+  customerAccessToken?: string,
+) {
+  if (customerAccessToken) {
+    const { data } = await client.fetch({
+      document: HomePageQuery,
+      customerAccessToken,
+      variables: { currencyCode },
+      locale,
+      fetchOptions: { cache: 'no-store' },
+    });
+
+    return data;
+  }
+
+  return getCachedPageData(locale, currencyCode);
+}

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 6, 2026

🦋 Changeset detected

Latest commit: cd035fa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@bigcommerce/catalyst-core Minor
@bigcommerce/catalyst-client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 6, 2026

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

Project Deployment Actions Updated (UTC)
catalyst Ready Ready Preview, Comment Apr 10, 2026 4:21pm

Request Review

@jorgemoya jorgemoya force-pushed the catalyst-1787-unstable-cache-requests branch from 4daa258 to b4cfed3 Compare March 6, 2026 18:31
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 6, 2026

Bundle Size Report

Comparing against baseline from a8dd99e (2026-04-10).

No bundle size changes detected.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 10, 2026

Unlighthouse Performance Comparison — Vercel

Comparing PR preview deployment Unlighthouse scores vs production Unlighthouse scores.

Summary Score

Aggregate score across all categories as reported by Unlighthouse.

Prod Desktop Prod Mobile Preview Desktop Preview Mobile
Score 92 94 91 95

Category Scores

Category Prod Desktop Prod Mobile Preview Desktop Preview Mobile
Performance 81 98 56 92
Accessibility 95 91 95 95
Best Practices 100 95 95 100
SEO 100 100 88 100

Core Web Vitals

Metric Prod Desktop Prod Mobile Preview Desktop Preview Mobile
LCP 2.9 s 2.3 s 3.4 s 3.3 s
CLS 0 0 0.479 0
FCP 1.1 s 1.1 s 1.2 s 1.2 s
TBT 0 ms 0 ms 0 ms 0 ms
Max Potential FID 50 ms 20 ms 50 ms 50 ms
Time to Interactive 2.9 s 2.3 s 3.4 s 3.3 s

Full Unlighthouse report →

@chanceaclark
Copy link
Copy Markdown
Contributor

@jorgemoya I hate to ask this as you've already done the work, but is there any way to separate out caching the guest queries into smaller PRs then when that is all done, add the correlation id header?

@jorgemoya
Copy link
Copy Markdown
Contributor Author

@chanceaclark no problem! 👍

@jorgemoya
Copy link
Copy Markdown
Contributor Author

Closing this to split into multiple PRs

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants