Which component is affected?
Qwik City (routing)
Describe the bug
recursiveScan() in the catch-all route matcher enters an infinite loop when the request path contains a repeated slash (//), as long as at least one route in the app has a catch-all segment ([...param]).
This is not a crash. No exception is thrown, the process does not exit, and nothing is logged. The Node process simply pins one CPU core at 100% and permanently stops serving requests — including all subsequent, perfectly valid ones. The listening socket stays open, so the kernel keeps completing TCP handshakes without accept(). Any L4/TCP health check therefore reports the service as healthy while it serves nothing.
In our case a single bot request took down a production storefront for 40+ minutes; it only recovered after a manual container restart. Because there is no crash and no log line, nothing in the stack indicated a problem.
Root cause
packages/qwik-city/src/runtime/src/routing.ts — in the published package lib/index.qwik.mjs, lines 410–440:
function recursiveScan(paramName, suffix, path, pathStart, pathLength, route, routeStart, routeLength) {
if (path.charCodeAt(pathStart) === 47) { pathStart++; }
let pathIdx = pathLength;
const sep = suffix + "/";
while (pathIdx >= pathStart) {
const match = matchRoutePart(route, routeStart, routeLength, path, pathIdx, pathLength);
if (match) { /* … */ return match; }
const newPathIdx = lastIndexOf(path, pathStart, sep, pathIdx, pathStart - 1) + sep.length;
if (pathIdx === newPathIdx) { break; } // ← only catches a stalled index
pathIdx = newPathIdx; // ← may move FORWARD
}
return null;
}
function lastIndexOf(text, start, match, searchIdx, notFoundIdx) {
let idx = text.lastIndexOf(match, searchIdx);
if (idx == searchIdx - match.length) {
idx = text.lastIndexOf(match, searchIdx - match.length - 1);
}
return idx > start ? idx : notFoundIdx;
}
The scan is meant to walk backwards through the path, one separator at a time. With // in the path, lastIndexOf() alternates between two values, so pathIdx oscillates instead of decreasing — it moves backward, then forward, then backward again.
For path = "/produkt//a-b.html", pathStart = 1, sep = "/":
pathIdx |
text.lastIndexOf("/", pathIdx) |
idx == searchIdx - 1? |
returns |
newPathIdx |
| 10 |
9 |
yes → retries from 8 → 8 |
8 |
9 |
| 9 |
9 |
no |
9 |
10 |
| 10 |
… |
|
|
9 … |
The guard if (pathIdx === newPathIdx) only detects a stalled index, never a two-element cycle, so the loop never terminates.
Reproduction
Both functions below are copied verbatim from @builder.io/qwik-city@1.20.0 (lib/index.qwik.mjs). Only the call to matchRoutePart is omitted, since the defect is purely in how pathIdx advances:
function lastIndexOf(text, start, match, searchIdx, notFoundIdx) {
let idx = text.lastIndexOf(match, searchIdx);
if (idx == searchIdx - match.length) {
idx = text.lastIndexOf(match, searchIdx - match.length - 1);
}
return idx > start ? idx : notFoundIdx;
}
function scanLoop(path, pathStart = 1, suffix = "", maxSteps = 14) {
const sep = suffix + "/";
let pathIdx = path.length, trace = [];
for (let i = 0; i < maxSteps; i++) {
trace.push(pathIdx);
const newPathIdx = lastIndexOf(path, pathStart, sep, pathIdx, pathStart - 1) + sep.length;
if (pathIdx === newPathIdx) return { trace, verdict: "terminates" };
pathIdx = newPathIdx;
if (pathIdx < pathStart) return { trace, verdict: "terminates" };
}
return { trace, verdict: "INFINITE LOOP" };
}
for (const p of ["/produkt/a-b.html", "/produkt//a-b.html", "/a//b", "/x/y//z/w"]) {
const r = scanLoop(p);
console.log(`${p.padEnd(22)} pathIdx: ${r.trace.join(" -> ")} => ${r.verdict}`);
}
Output:
/produkt/a-b.html pathIdx: 17 -> 9 -> 1 => terminates
/produkt//a-b.html pathIdx: 18 -> 10 -> 9 -> 10 -> 9 => INFINITE LOOP
/a//b pathIdx: 5 -> 4 -> 3 -> 4 -> 3 => INFINITE LOOP
/x/y//z/w pathIdx: 9 -> 8 -> 6 -> 5 -> 6 -> 5 => INFINITE LOOP
Any path containing // loops — the position of the repeated slash does not matter.
Steps to reproduce
- Create a Qwik City app that has at least one route with a catch-all segment, e.g.
src/routes/[...lang]/product/[slug]/index.tsx. (The default starter has no catch-all route, which is likely why this went unnoticed.)
- Build and run the production server (
entry.express / entry.node-server).
- Request any path with a repeated slash, e.g.
GET /product//some-item.html.
- The request never completes. The process now uses 100% of one CPU core and stops answering all further requests, including previously working ones. It never recovers on its own.
A stack sample taken from the stuck process (V8 inspector, Debugger.pause) shows the loop:
#0 matchRoutePart
#1 recursiveScan
#2 matchRoutePart
#3 matchRoute
#4 loadRoute ← 99.7% self time in the CPU profile
#5 loadRequestHandlers
#6 resolveRequestHandlers
#7 router
Relation to previously reported issues
This looks superficially similar to two closed issues, but the failure mode is different:
Both were about the server throwing. This one hangs: no exception, no log output, process stays alive and is reported healthy by TCP health checks. It also requires a catch-all route, which neither reproduction had. Verified still present in 1.20.0 (current latest).
Suggested fix
Backtracking must be monotonic — the index may never move forward:
- if (pathIdx === newPathIdx) { break; }
+ if (newPathIdx >= pathIdx) { break; }
Normalising repeated slashes before routing would also help, but the loop guard should be correct regardless of input.
System Info
@builder.io/qwik-city: 1.20.0 (current latest on npm)
@builder.io/qwik: 1.20.0
Node: v24.16.0
Server: entry.express, production build
OS: Linux (Docker container)
Additional Information
The impact is amplified by the silence of the failure. Because the process neither exits nor logs, orchestrators do not restart it: Docker Swarm sees a running container, and an L4 health check sees an open port. We measured L4OK from HAProxy at the same moment the socket had 14 connections sitting unaccepted in its backlog queue. Switching to an HTTP-level health check is what finally made the condition visible.
Which component is affected?
Qwik City (routing)
Describe the bug
recursiveScan()in the catch-all route matcher enters an infinite loop when the request path contains a repeated slash (//), as long as at least one route in the app has a catch-all segment ([...param]).This is not a crash. No exception is thrown, the process does not exit, and nothing is logged. The Node process simply pins one CPU core at 100% and permanently stops serving requests — including all subsequent, perfectly valid ones. The listening socket stays open, so the kernel keeps completing TCP handshakes without
accept(). Any L4/TCP health check therefore reports the service as healthy while it serves nothing.In our case a single bot request took down a production storefront for 40+ minutes; it only recovered after a manual container restart. Because there is no crash and no log line, nothing in the stack indicated a problem.
Root cause
packages/qwik-city/src/runtime/src/routing.ts— in the published packagelib/index.qwik.mjs, lines 410–440:The scan is meant to walk backwards through the path, one separator at a time. With
//in the path,lastIndexOf()alternates between two values, sopathIdxoscillates instead of decreasing — it moves backward, then forward, then backward again.For
path = "/produkt//a-b.html",pathStart = 1,sep = "/":pathIdxtext.lastIndexOf("/", pathIdx)idx == searchIdx - 1?newPathIdxThe guard
if (pathIdx === newPathIdx)only detects a stalled index, never a two-element cycle, so the loop never terminates.Reproduction
Both functions below are copied verbatim from
@builder.io/qwik-city@1.20.0(lib/index.qwik.mjs). Only the call tomatchRoutePartis omitted, since the defect is purely in howpathIdxadvances:Output:
Any path containing
//loops — the position of the repeated slash does not matter.Steps to reproduce
src/routes/[...lang]/product/[slug]/index.tsx. (The default starter has no catch-all route, which is likely why this went unnoticed.)entry.express/entry.node-server).GET /product//some-item.html.A stack sample taken from the stuck process (V8 inspector,
Debugger.pause) shows the loop:Relation to previously reported issues
This looks superficially similar to two closed issues, but the failure mode is different:
TypeError [ERR_INVALID_URL]on trailing slashes; a crash, fixed in 2024.Both were about the server throwing. This one hangs: no exception, no log output, process stays alive and is reported healthy by TCP health checks. It also requires a catch-all route, which neither reproduction had. Verified still present in 1.20.0 (current latest).
Suggested fix
Backtracking must be monotonic — the index may never move forward:
Normalising repeated slashes before routing would also help, but the loop guard should be correct regardless of input.
System Info
Additional Information
The impact is amplified by the silence of the failure. Because the process neither exits nor logs, orchestrators do not restart it: Docker Swarm sees a running container, and an L4 health check sees an open port. We measured
L4OKfrom HAProxy at the same moment the socket had 14 connections sitting unaccepted in its backlog queue. Switching to an HTTP-level health check is what finally made the condition visible.