index.txt after session endsStatus: root cause confirmed · fix implemented and verified
Component: bridge-ui · Next.js 15.5.3 · output: "export" · S3 + CloudFront
Investigated: 17 August 2026, against bridge-ui@5ef551b
All line numbers refer to the installed package under
node_modules, not to this repository.
In output: "export" builds, Next appends /index.txt to the URL to fetch a route's RSC payload.
Three code paths can abandon that fetch and hand control back to the browser. Two strip the
suffix first. The third — the catch — does not, so the browser is sent to the .txt file
itself, which S3 serves as text/plain.
Anything that makes that fetch fail or be cancelled lands the user on the payload. This is a Next.js defect, fixed upstream in Next 16 and not backported.
A Next.js defect. In fetchServerResponse, the catch returns the RSC payload URL without
stripping the /index.txt suffix that its two sibling bail-outs both strip.
next@15.5.3 · fixed in 16 · not backported (15.5.23 still affected)
| Trigger | Reliability |
|---|---|
| The payload fetch fails at the network layer | Reproduces every time |
The shared AbortController is left aborted after pagehide with no pageshow |
Reproduces every time |
| Session-expiry logout races the route guard's navigation, and the page unload cancels the fetch | 2 observed, then 0 of 18 |
Production serves no Cache-Control on any object and each release invalidates /*, so every
deploy returns the whole asset graph to origin. That raises the payload-fetch failure rate exactly
when people return to idle tabs — consistent with the reported clustering, though not measured.
NGINX/Apache rules, the index.html entry point, response Content-Type, backend 401 handling,
SPA fallback, deployment artifacts, and missing files were each checked and cleared. See
Scope of investigation.
| # | Solution | Status |
|---|---|---|
| 1 | Patch the defective branch — postinstall script rewriting the catch to use doMpaNavigation. Removes the cause for every route and trigger. |
Implemented · verified |
| 2 | CloudFront guard — redirects any .txt requested as a page to the stripped path. Independent of the client, so it also protects users still running a cached older bundle. |
Written · verified · needs attaching |
| 3 | Cache headers and a narrower invalidation — immutable on _next/static/, excluded from the purge. Reduces the trigger rate. |
Proposed |
| 4 | Upgrade to Next 16 — the defect is already fixed upstream, which retires solution 1 entirely. | Proposed · not for an incident |
Solutions 1 and 2 together are the complete answer: the first removes the defect, the second does not depend on the client being correct. Solutions 3 and 4 are follow-up work.
.txt files exist at allbridge-ui is a Next.js App Router app built with output: "export" and trailingSlash: true,
deployed as static files to S3 behind CloudFront. There is no server at runtime — every page is
pre-rendered.
Client-side navigation still needs the server component tree for the destination route. With no
server to ask, the export writes that tree to disk next to each page. out/ currently holds 27:
out/index.html ← the page
out/index.txt ← its RSC payload
out/login/index.html
out/login/index.txt
out/members/index.html
out/members/index.txt
… 27 index.txt files in total
These files are correct, expected build output. Serving them as text/plain is also correct.
Neither is the bug — both are why the bug is so visible when it fires: a text/plain response
renders in the viewport instead of downloading.
All of this lives in one file:
node_modules/next/dist/client/components/router-reducer/fetch-server-response.js
On every client-side navigation it rewrites the URL, then fetches it (lines 96–105):
if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
url = new URL(url);
if (url.pathname.endsWith('/')) url.pathname += 'index.txt';
else url.pathname += '.txt';
}
const res = await createFetch(url, headers, fetchPriority, abortController.signal);
url is now /login/index.txt, and it stays that way for the rest of the function. That single
detail is the whole bug.
Three things can go wrong from here. Each hands the URL to the browser as a full page load:
| Condition | Hands over | |
|---|---|---|
Response isn't a flight payload, isn't 200, or has no body |
:123 → doMpaNavigation(responseUrl.toString()) |
strips |
| Payload is from a different build than the running bundle | :140 → doMpaNavigation(res.url) |
strips |
| The fetch or the payload parse throws | :151 → { flightData: url.toString() } |
never stripped |
Both safe paths route through one helper, and the helper is where the stripping happens (line 38):
function doMpaNavigation(url) {
return {
flightData: urlToUrlWithoutFlightMarker(new URL(url, location.origin)).toString(),
…
};
}
which trims the suffix back off — route-params.js:141–149:
const length = pathname.endsWith('/index.txt') ? 10 : 4;
urlWithoutFlightParameters.pathname = pathname.slice(0, -length);
The catch is the one path that never calls it — fetch-server-response.js:151–162:
} catch (err) {
if (!abortController.signal.aborted) {
console.error("Failed to fetch RSC payload for " + url +
". Falling back to browser navigation.", err);
}
return {
flightData: url.toString(), // ← still ".../login/index.txt"
canonicalUrl: undefined,
…
};
}
.txt can reach the address barSearching the entire client runtime, the suffix is appended in exactly one function:
dist/client/…/fetch-server-response.js:99 url.pathname += 'index.txt';
dist/client/…/fetch-server-response.js:101 url.pathname += '.txt';
dist/esm/client/…/fetch-server-response.js:78 (same file, ESM copy)
dist/esm/client/…/fetch-server-response.js:80
and that function has exactly four exits:
:128 return doMpaNavigation(responseUrl.toString()); strips
:141 return doMpaNavigation(res.url); strips
:143 return { flightData: normalizeFlightData(…) }; success
:158 return { flightData: url.toString() }; defect
Every other reducer that can trigger a hard navigation — refresh, server-patch, HMR — calls this
same function, so they all inherit the one defect rather than adding new ones. Combined with a
deployment that contains no other .txt files and links to none, this is not merely a path to
the symptom; it is the only one.
A flightData that is a string rather than a tree is Next's internal signal for "give up on the
SPA and let the browser load this URL."
navigate-reducer.js:222if (typeof flightData === 'string') {
return handleExternalUrl(state, mutable, flightData, pendingPush);
}
navigate-reducer.js:44function handleExternalUrl(state, mutable, url, pendingPush) {
mutable.mpaNavigation = true;
mutable.canonicalUrl = url; // ← the .txt URL
app-router.js:270–277if (pushRef.mpaNavigation) {
if (pushRef.pendingPush) location.assign(canonicalUrl);
else location.replace(canonicalUrl);
Content-Type: text/plain, status 200. Verified against
production directly.1:"$Sreact.fragment"
2:I[41402,["1659","static/chunks/1659-59ad6396e1451f89.js", …
The session-expiry case goes through router.push, so it takes the location.assign branch and
the broken URL is added to history as a normal entry.
This is the ticket's own scenario. The app manufactures the failed fetch itself.
When the session timer fires, contexts/auth-context.tsx runs:
const logout = async () => {
clearSessionTimer();
try {
clearClarityUser();
await authService.logout();
setUser(null); // ← re-renders; the guard now sees "not authenticated"
redirectToLanding(); // ← window.location.href = origin + '/'
} catch { … same two calls … }
};
Those two lines race:
setUser(null) re-renders.
MembersRouteGuard.tsx:24 sees !isAuthenticated and calls router.push('/'), which begins
fetching /index.txt.redirectToLanding() sets window.location.href.
The browser begins unloading the document.TypeError: Failed to fetch — a genuine failure, not an abort, so it is not covered by the
signal.aborted guard and is logged.catch returns the unstripped .txt URL, and
location.assign supersedes the pending navigation to /.Reproducibility, stated honestly. This sequence was observed twice, with the console error and navigation trail captured both times. It then failed to reproduce in 18 further attempts across varied session timings, with the API healthy and unreachable, and with payload responses artificially slowed to imitate a cold CDN. The race window is evidently very narrow.
So: the sequence is real and the code plainly permits it, but it is not the confirmed production trigger — it is a demonstrated instance of the general failure.
A failed network request. The payload fetch is a normal fetch(). A dropped connection, a CDN
hiccup, a blocked request, a phone moving between cells — any of these reject with
TypeError: Failed to fetch.
An aborted controller. More interesting, because it needs no failure at all. The module keeps
one AbortController shared by every payload fetch — fetch-server-response.js:48–61:
let abortController = new AbortController();
window.addEventListener('pagehide', () => { abortController.abort(); });
window.addEventListener('pageshow', () => { abortController = new AbortController(); });
pagehide fires when a tab is backgrounded, put into the back/forward cache, or navigated away
from. The controller is only ever restored by pageshow. Any sequence where the first event fires
without the second leaves the controller permanently aborted — and from then on every navigation
in that tab rejects instantly. This path also skips the console.error, so it fails silently.
All three routes converge on MembersRouteGuard.tsx:24, which calls router.push('/') once the
user is no longer authenticated. Root / is why the ticket reports a bare index.txt.
The 401 handler in
services/http-client.ts:82useswindow.location.href, a real page load. It is not affected. The exposure is the route guard, not the API layer.
All on an unmodified Next 15.5.3 and a correctly configured static host.
| Reproduction | Result |
|---|---|
The ticket's own steps — real session seeded to expire in 3s, /members/ opened and left alone |
→ /kyc/error/index.txt, text/plain. Observed twice, then 0 of 18 |
Forced network failure — connection dropped for payload requests only, /members/ opened logged out |
→ /index.txt, with Failed to fetch RSC payload logged |
Aborted controller — healthy server, pagehide dispatched, then a link clicked |
→ /invitation-only/index.txt. No server fault involved |
End-to-end, app untouched — real /landing/, real link click, one payload request failed at the network layer |
→ /login/index.txt, text/plain |
| Trigger | Reliability | Needs |
|---|---|---|
| Payload fetch fails at the network layer | Every time | A failed request |
pagehide without pageshow |
Every time | Nothing — healthy server |
| Session-expiry logout race | 2 observed, then 0 of 18 | Winning a narrow race |
| Link click while offline | Did not reproduce | Payload not already prefetched |
| Navigation then immediate reload | Did not reproduce | — |
Two things follow. First, <Link> prefetching hides most of the exposure: by the time a link is
clicked its payload is usually cached, so no fetch happens and nothing can fail. The vulnerable
navigations are router.push() calls to routes that no visible link points at — such as
/kyc/error, and / from inside the members area. Second, the rarity is a property of the
trigger, not of the defect: whenever a payload fetch does fail, landing on the payload is
certain, not probable.
A deterministic on-demand repro is checked in at app/br655/page.tsx — deploys to /br655/, one
button. It must be run against a static export; the .txt code path is compiled out unless
NODE_ENV=production and output: "export", so the bug cannot appear under next dev. That
is also why it was never caught in development.
npm run build-static
npm run serve-static # then open /br655/
Delete
app/br655/page.tsxonce the fix is confirmed. It is a publicly reachable page that deliberately breaks navigation.
Every line of the ticket's investigation scope was checked. All seven came back clean — the fault is in the client bundle, not in any of them.
| Checked | Finding |
|---|---|
| NGINX / Apache fallback | Not used in production. The stack is S3 + CloudFront; bridge-ui/nginx.strict.conf is dead config. |
index.html as entry point |
Correct. |
| Response headers | /index.txt returns 200 text/plain. Correct — and the reason it renders rather than downloads. |
| Backend 401 handling | Not involved. The API interceptor does a hard navigation. |
| SPA fallback | Not applicable. Every route is pre-rendered. |
| Deployment artifacts | No misconfiguration. The .txt files are meant to be there. |
| CDN / cache | Not the cause. All 20 chunks referenced by production index.html resolve 200. |
A missing payload was checked specifically, since it is the intuitive suspect:
/no-such-route/index.txt returns 404 text/html, which is the stripping branch. Absent files
cannot produce this symptom.
A stale tab meeting a new build does not cause it. Two different builds were produced and one
swapped in under a live tab, mirroring aws s3 cp followed by a /* invalidation. The tab
navigated correctly, because the build-mismatch branch (:141) is one of the two that strips.
A limit of that test: it ran against a local server with a zero percent failure rate. It proves build mismatch is not directly causal. It cannot rule out an effect driven by request failure rate, because there were no failures available to observe.
A release is when people come back to tabs they left open for hours — and a tab left open for hours is a tab whose session has expired. The deploy is not causal; it is simply the event that makes a group of people wake stale sessions at roughly the same moment.
A release does also raise the background failure rate. The pipeline ends with
create-invalidation --paths "/*", purging every object at every edge. Production serves no
Cache-Control header on any object:
GET /_next/static/chunks/webpack-d10cbd12d1ba549a.js
200 · content-type: text/javascript · no cache-control
GET /login/ 200 · text/html · no cache-control
GET /index.txt 200 · text/plain · no cache-control
Hashed filenames are immutable by construction and should be served
max-age=31536000, immutable and kept out of the invalidation.
/kyc/error
instead of /, which is why the observed file was /kyc/error/index.txt rather than the bare
index.txt in the ticket.To close these: search session recordings for Failed to fetch RSC payload immediately before a
redirect, and ask one reporter whether the URL was bare index.txt or a nested one. The fix does
not depend on the answers — every route runs through the same catch.
Next 16 keeps a reference to the URL from before the rewrite:
// In static export mode, we need to modify the URL to request the .txt file,
// but we should preserve the original URL for the canonical URL and error handling.
const originalUrl = url;
…
return originalUrl.toString();
Verified by diffing the published packages:
| Version | Status | Note |
|---|---|---|
15.5.3 |
Affected | Currently in production |
15.5.23 |
Affected | Latest 15.5 backport line — fix absent |
16.3.1 |
Fixed | Uses originalUrl |
The same defective code was confirmed live in the production bundle at
/_next/static/chunks/1255-522eae97879766d9.js.
The fix should not depend on knowing the trigger. These two layers together cover the whole class: the first removes the defect, the second catches anything that still reaches a payload URL — including users whose browsers are still running a cached older bundle after the patch ships.
doMpaNavigation already returns an identical object shape, so the correction is a true one-liner:
// fetch-server-response.js, in the catch
return doMpaNavigation(url.toString());
Shipped as scripts/patch-next-br655.mjs, wired to postinstall, which runs after npm i in the
pipeline and before the build. It is idempotent, and it throws if the expected code is absent,
so a future Next upgrade fails loudly rather than silently dropping the fix.
Not patch-package: installing it fails with ERESOLVE on a pre-existing peer conflict between
@vitejs/plugin-react and vite@8.2.1. That conflict is unrelated to this ticket, but adding any
dependency forces a re-resolve.
Both copies must be patched — Next ships dist/client/… and dist/esm/client/…, and the client
build uses the ESM one.
The edit is one line. The script exists because the defective code lives in node_modules, which
is not in version control and is rebuilt by npm i on every CI run.
| Approach | Outcome |
|---|---|
Edit node_modules directly |
Wiped by the next install |
Commit a .patch, apply with git apply |
Same shape, more brittle across line endings |
| Vendor the file and alias it in webpack | Replaces ~170 lines instead of one, and owns a private copy of a Next internal that imports eight others |
Intercept location.assign in app code |
Impossible — see below |
Stop using router.push in the guards |
Partial; does not cover <Link> clicks |
The last-moment interception looks attractive — catch Next's own location.assign(canonicalUrl) in
our code and strip the suffix there. It does not work; Location is protected by the browser:
window.location.assign = fn; → silently ineffective
Location.prototype.assign → undefined (not an overridable prototype method)
…the browser navigated to /test/index.txt anyway.
Run against the ticket's own scenario — expiring session on /members/ — with only the bundle
differing:
| Build | Navigation trail | Ends on | Content type |
|---|---|---|---|
| Unpatched | /members/ → /kyc/error/index.txt |
Raw payload | text/plain |
| Patched | /members/ → /kyc/error → / |
Landing page | text/html |
The patched build recovers to the landing page — the ticket's stated Expected Result.
Trap worth recording. The first rebuild after patching produced a byte-identical chunk — same hash, still the defective code. Webpack's filesystem cache under
.next/cache(360 MB) served the stale module. After clearing it the hash changed and the emitted code becamep(e.toString()), the minifieddoMpaNavigation. CI is unaffected — the pipeline cachesnode, not.next— but any local verification must clear it first, or it will confirm a fix that is not in the bundle.
One behavioural note: stripping /index.txt yields /login without a trailing slash. With
trailingSlash: true this was checked against production directly — CloudFront returns
302 → /login/, so it resolves correctly at the cost of one extra round trip.
A CloudFront viewer-request function, checked in at infra/cloudfront-rsc-txt-guard.js. It
redirects a request for a .txt to the stripped path only when the browser is loading it as a
page:
var dest = headers['sec-fetch-dest'] && headers['sec-fetch-dest'].value;
if (dest && dest !== 'document') return request; // payload fetch — untouched
A genuine payload fetch is a sub-resource and sends Sec-Fetch-Dest: empty; a browser landing on
the URL sends document. For clients that omit the header, it falls back to requiring no RSC
header and an Accept that asks for HTML. It also drops Next's _rsc cache-busting parameter.
Safe on this distribution because it contains zero legitimate .txt files — there is no
robots.txt. That must be re-checked before reusing it elsewhere.
This file is inert until deployed. It has to be uploaded and attached to the viewer-request event on the distributions:
| Environment | Distribution |
|---|---|
| Production | E1G87BUL2ZQ79Y |
| Staging | E8PIFFYJ9T8RO |
Tested by running the guard in front of the unpatched build, so the defect was still present:
| Case | Result | Content type |
|---|---|---|
| Ordinary link click | /login/ |
text/html |
| The deterministic trigger | /login/ (was /login/index.txt) |
text/html |
| Payload URL opened directly | /members/ → / |
text/html |
The guard alone eliminates the user-visible symptom without any client change, and does not disturb normal navigation. That is what makes the pair universal: layer one is correct, layer two does not depend on the client being correct.
Independent of the defect, aimed at the trigger rate. Content-hashed assets under _next/static/
should be served Cache-Control: max-age=31536000, immutable and excluded from the release
invalidation; only the HTML and payloads need purging.
The durable fix, and a major version bump. It makes the patch script redundant — the script's own error message says so. Not something to do under an incident.