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.
Prerequisites #
- A Node host rendering remotes, per configuring Node federation for SSR remotes.
- @tanstack/react-query
^5.51.0for the cache, or an equivalent with dehydrate/hydrate support. - Request context available on the server via
AsyncLocalStorage, so loaders can read the current user without module-level state. - Agreement that remotes never call
fetchdirectly during render — that rule is what makes the rest work.
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 #
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 #
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 #
- The data is in the HTML:
curl -s $URL | grep -o '__QUERY_STATE__.\{0,120\}'should show real data before any JavaScript runs. - No duplicate requests: load the page and confirm each API path appears at most once in the Network panel.
- Deadlines work: delay one downstream API by two seconds and confirm the response still arrives promptly with that region client-fetching.
- Nothing leaks: search the served HTML for an internal field name that should never be serialised. Zero matches.
- Escaping holds: have a fixture return a string containing
</script>and confirm the page still parses. - Aborts are real: with a delayed API, confirm the server-side request is actually cancelled rather than completing after the response was sent.
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.