Sharing Data Fetching Between Host and Remote on the Server #

A server-rendered remote that fetches its own data will fetch it again the moment it hydrates — this page gives remotes a declarative way to state what they need, fetches it once on the server, and serialises it into the page.

The pattern is a small contract rather than a framework. Each remote exports a loader alongside its component; the host runs every loader in parallel, dehydrates the results into the HTML, and the client rehydrates before any component renders. It completes the picture begun in server-side rendering federated modules.

Why a server-rendered remote fetches twice by default Server-side render output is HTML, not cache state, so unless the fetched data is serialised the client has no way to know it already exists. Remote fetches for itself Server render triggers a fetch Data is used, then thrown away Component mounts on the client and refetches Every API called twice per page view Remote declares, host fetches Loader exported beside the component Host runs every loader in parallel Results dehydrated into the HTML Client rehydrates and fetches nothing
The duplicate request is invisible in the rendered page and obvious in the network panel — which is why it survives so long.

Prerequisites #

Step 1 — Have each remote export a loader beside its component #

The loader is a pure function of the request context. It does not render, does not touch the DOM, and does not know whether it is running on the server.

// remote/src/Widget.loader.js — exposed alongside ./Widget
export const queries = ({ productId, userId }) => [
  {
    queryKey: ['recommendations', productId, userId],
    queryFn: ({ signal }) =>
      fetch(`/api/recommendations?product=${productId}&user=${userId}`, { signal })
        .then((r) => { if (!r.ok) throw new Error(`recs ${r.status}`); return r.json(); }),
    staleTime: 60_000,
  },
];
// remote/webpack.server.js and webpack.browser.js — expose both
exposes: {
  './Widget': './src/Widget',
  './Widget.loader': './src/Widget.loader',
},

Exposing the loader in both targets is what lets the client reuse the same query keys. If the keys differ by a single character, the client treats the server’s data as a different query and fetches again — which is the exact failure this whole design exists to prevent.

Step 2 — Let the host discover and run every loader #

The host collects the loaders for the remotes on the route, runs them concurrently, and never lets one slow query hold the rest.

// server/run-loaders.js
export async function runLoaders(remotes, ctx) {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });

  await Promise.allSettled(remotes.map(async (name) => {
    // A remote without a loader is fine — it simply fetches on the client.
    const mod = await loadServerRemote(name, './Widget.loader').catch(() => null);
    if (!mod?.queries) return;

    await Promise.allSettled(
      mod.queries(ctx).map((q) => client.prefetchQuery(q)),
    );
  }));

  return client;
}

Promise.allSettled at both levels is deliberate. One remote’s failed query must not prevent another remote’s data from reaching the page, and a remote with no loader at all must not be an error.

Step 3 — Bound every query with a deadline #

Loader latency, run in parallel with deadlines Running loaders concurrently means the server waits for the slowest one, and the deadline caps how slow that is allowed to be. Where the server-side data wait goes Host shell data 60 ms reviews loader 90 ms recommendations loader 130 ms pricing loader (deadline hit) 300 ms cap Total server wait (parallel) 300 ms Bars are relative to the 430 ms these loaders would take if run sequentially.
Without the cap on the fourth bar, one slow downstream service sets the response time for every user.

Server-side fetching moves the latency of every remote’s API onto your response. Without a deadline, the slowest downstream service decides your Time to First Byte.

// server/with-deadline.js
export function withDeadline(query, ms = 300) {
  return {
    ...query,
    queryFn: async (ctx) => {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), ms);
      try {
        // Pass the signal through so the underlying fetch is actually cancelled.
        return await query.queryFn({ ...ctx, signal: controller.signal });
      } finally {
        clearTimeout(timer);
      }
    },
  };
}

Aborting rather than merely racing matters: a raced promise leaves the request in flight, holding a socket and consuming downstream capacity for a response nobody will read. Under load that is the difference between a slow page and a saturated connection pool.

A query that misses its deadline is not an error. It simply means that data will be fetched on the client, and the component should render its loading state on the server exactly as it would if there had been no loader at all.

Step 4 — Dehydrate into the HTML #

The server’s cache is serialised into the page so the client starts warm.

// server/render.js
import { dehydrate } from '@tanstack/react-query';

const client = await runLoaders(remotesForRoute, ctx);
const state = dehydrate(client);

const html = renderToString(
  <HydrationBoundary state={state}><App /></HydrationBoundary>,
);

res.send(`<!doctype html>
<html><body>
  <div id="root">${html}</div>
  <script>window.__QUERY_STATE__ = ${safeJson(state)};</script>
  <script src="/client.js" defer></script>
</body></html>`);
// server/safe-json.js — a </script> inside fetched data would end the block early
export const safeJson = (value) =>
  JSON.stringify(value)
    .replace(/</g, '\\u003c')
    .replace(/
|
/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`);

Escaping is not optional here. The serialised state contains data from downstream services, which means it can contain arbitrary strings — including ones an end user typed.

Step 5 — Serialise only what the client may see #

This is the step that turns a performance feature into a security problem when skipped. Everything dehydrated into the HTML is readable by anyone who views source, including the user’s browser extensions.

// server/scrub.js — allow-list, never deny-list
const CLIENT_SAFE = new Set(['recommendations', 'reviews', 'product', 'catalog']);

export function scrub(state) {
  return {
    ...state,
    queries: state.queries.filter(([key]) => CLIENT_SAFE.has(key[0])),
  };
}

An allow-list rather than a deny-list is the whole point. A deny-list protects the fields someone remembered; an allow-list protects everything nobody thought about, including a field a downstream service adds next month.

Internal identifiers, permission sets, pricing internals and anything from an authenticated admin API should stay on the server. If a remote needs derived information — “this user may see wholesale prices” — send the derived boolean rather than the data it was derived from.

Step 6 — Rehydrate before anything renders #

Rehydrating the cache before the first render The cache is populated from the serialised state before any component mounts, so the first render has data and no query is considered stale. Server HTML Client cache Component 1. dehydrate scrubbed state 2. rehydrate before first render 3. data available synchronously 4. renders content, not a spinner 5. no fetch — within staleTime
The last step is the one that needs configuration: with a zero staleTime the cache is warm and every query refetches anyway.

The client must populate its cache before the first render, or components mount in a loading state and immediately fetch.

// client/index.jsx
import { QueryClient, QueryClientProvider, HydrationBoundary } from '@tanstack/react-query';
import { hydrateRoot } from 'react-dom/client';

const client = new QueryClient({
  defaultOptions: {
    queries: {
      // Trust the server's data for its stale window; do not refetch on mount.
      staleTime: 60_000,
      refetchOnMount: false,
      refetchOnWindowFocus: false,
    },
  },
});

hydrateRoot(
  document.getElementById('root'),
  <QueryClientProvider client={client}>
    <HydrationBoundary state={window.__QUERY_STATE__}>
      <App />
    </HydrationBoundary>
  </QueryClientProvider>,
);

refetchOnMount: false combined with a non-zero staleTime is what actually prevents the duplicate request. With the defaults, the cache is hydrated and then immediately considered stale, so every query refetches on mount and the server work is wasted — the page looks right, and the network panel shows every request twice.

Step 7 — Prove the data is fetched exactly once #

The failure here is invisible in the rendered output, so it needs an explicit test.

// test/no-double-fetch.spec.mjs
import { test, expect } from '@playwright/test';

test('server-fetched queries are not refetched on hydration', async ({ page }) => {
  const apiCalls = [];
  page.on('request', (req) => {
    if (req.url().includes('/api/')) apiCalls.push(new URL(req.url()).pathname);
  });

  await page.goto(`${process.env.PREVIEW_URL}/product/42`, { waitUntil: 'networkidle' });

  expect(apiCalls, `client refetched: ${apiCalls.join(', ')}`)
    .not.toContain('/api/recommendations');
});

Pair it with a scrubbing assertion — fetch the HTML and confirm the serialised state contains none of your internal field names. Both tests are short, and both catch regressions that are otherwise found in production.

Verification #

Troubleshooting #

Symptom: every query refetches immediately after hydration.

Diagnosis: staleTime is zero, so hydrated data is stale on arrival. Fix: set a staleTime at least as long as the data’s real freshness and disable refetchOnMount for hydrated queries.

Symptom: the client fetches despite matching data being present.

Diagnosis: the query keys differ between server and client — commonly one side includes a value the other does not, such as a locale or a user id. Fix: expose the loader from both build targets and construct keys in one shared function.

Symptom: Time to First Byte regressed after adding loaders.

Diagnosis: loaders run without deadlines, so downstream latency is now on your critical path. Fix: apply per-query deadlines as in step 3 and treat a miss as a client fetch.

Symptom: the page fails to parse for some products.

Diagnosis: unescaped content in the serialised state — usually a </script> or a line separator inside user-generated text. Fix: escape with the helper in step 4.

Symptom: a field appears in the HTML that should not be public.

Diagnosis: the scrubbing step uses a deny-list, and a new field was added upstream. Fix: switch to the allow-list in step 5 and assert on it in CI.

Keeping the loader contract from becoming a framework #

The temptation once this works is to grow the contract: lifecycle hooks, dependency ordering between loaders, a way for one remote to consume another’s data, caching policies expressed in the manifest. Each addition is individually reasonable and the aggregate is a bespoke framework that every remote team has to learn and that only your organisation maintains.

Two constraints keep it small. First, loaders may not depend on each other. If remote A needs something remote B fetched, that is a signal the boundary is wrong — the shared data belongs to the host, which fetches it once and passes it into both loaders as part of the request context. Sequencing loaders re-introduces exactly the cross-team coupling that defining application boundaries exists to remove, and it turns a parallel wait into a serial one.

Second, the loader signature stays a pure function of context to query descriptors. No side effects, no rendering, no access to the response object. That restriction is what lets the host run loaders in any order, in parallel, with deadlines, and skip them entirely when a route does not need them — none of which is safe once a loader can do arbitrary work.

Where a remote genuinely needs behaviour the contract does not express, the honest answer is usually that it should fetch on the client for that case. A remote that renders slightly later is a much smaller cost than a shared contract nobody can change without coordinating five teams.