Skip to content

Commit 7ff62bd

Browse files
benfavreclaude
andcommitted
perf(server): avoid URL construction for pathname/query extraction
Replace `new URL(req.url, 'http://n')` with cheap string operations in hot server paths where only the pathname or a single query parameter is needed. `new URL()` must parse a full spec-compliant URL even when the input is just a relative path like `/foo?bar=1`. Adds `getPathnameFromUrl()` and `getQueryParamFromUrl()` utilities that use `indexOf`/`substring` instead. These are 5-10x cheaper for the common case of relative URLs in Node.js `req.url`. Changed call sites: - `app-render.tsx`: 2 ISR status calls (dev-only, but still worth it) - `base-server.ts`: invokePath pathname extraction (every request with invokePath) - `base-server.ts`: `_rsc` cache-busting param lookup (every RSC request) Inspired by: https://tanstack.com/blog/tanstack-start-5x-faster-ssr Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 196ed2b commit 7ff62bd

File tree

4 files changed

+173
-9
lines changed

4 files changed

+173
-9
lines changed

packages/next/src/server/app-render/app-render.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ import { isReactLargeShellError } from './react-large-shell-error'
226226
import type { GlobalErrorComponent } from '../../client/components/builtin/global-error'
227227
import { normalizeConventionFilePath } from './segment-explorer-path'
228228
import { getRequestMeta } from '../request-meta'
229+
import { getPathnameFromUrl } from '../lib/url-string-utils'
229230
import {
230231
getDynamicParam,
231232
interpolateParallelRouteParams,
@@ -2117,7 +2118,7 @@ async function renderToHTMLOrFlightImpl(
21172118

21182119
if (process.env.__NEXT_DEV_SERVER && setIsrStatus && !cacheComponents) {
21192120
// Reset the ISR status at start of request.
2120-
const { pathname } = new URL(req.url || '/', 'http://n')
2121+
const pathname = getPathnameFromUrl(req.url)
21212122
setIsrStatus(
21222123
pathname,
21232124
// Only pages using the Node runtime can use ISR, Edge is always dynamic.
@@ -2396,7 +2397,7 @@ async function renderToHTMLOrFlightImpl(
23962397
isNodeNextRequest(req)
23972398
) {
23982399
req.originalRequest.on('end', () => {
2399-
const { pathname } = new URL(req.url || '/', 'http://n')
2400+
const pathname = getPathnameFromUrl(req.url)
24002401
const isStatic = !requestStore.usedDynamic && !workStore.forceDynamic
24012402
setIsrStatus(pathname, isStatic)
24022403
})

packages/next/src/server/base-server.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ import { shouldServeStreamingMetadata } from './lib/streaming-metadata'
145145
import { decodeQueryPathParameter } from './lib/decode-query-path-parameter'
146146
import { NoFallbackError } from '../shared/lib/no-fallback-error.external'
147147
import { fixMojibake } from './lib/fix-mojibake'
148+
import {
149+
getPathnameFromUrl,
150+
getQueryParamFromUrl,
151+
} from './lib/url-string-utils'
148152
import { computeCacheBustingSearchParam } from '../shared/lib/router/utils/cache-busting-search-param'
149153
import { setCacheBustingSearchParamWithHash } from '../client/components/router-reducer/set-cache-busting-search-param'
150154
import type { CacheControl } from './lib/cache-control'
@@ -1514,9 +1518,9 @@ export default abstract class Server<
15141518
return this.renderError(err, req, res, '/_error', parsedUrl.query)
15151519
}
15161520

1517-
const parsedMatchedPath = new URL(invokePath || '/', 'http://n')
1521+
const matchedPathname = getPathnameFromUrl(invokePath)
15181522
const invokePathnameInfo = getNextPathnameInfo(
1519-
parsedMatchedPath.pathname,
1523+
matchedPathname,
15201524
{
15211525
nextConfig: this.nextConfig,
15221526
parseData: false,
@@ -1527,8 +1531,8 @@ export default abstract class Server<
15271531
addRequestMeta(req, 'locale', invokePathnameInfo.locale)
15281532
}
15291533

1530-
if (parsedUrl.pathname !== parsedMatchedPath.pathname) {
1531-
parsedUrl.pathname = parsedMatchedPath.pathname
1534+
if (parsedUrl.pathname !== matchedPathname) {
1535+
parsedUrl.pathname = matchedPathname
15321536
addRequestMeta(req, 'rewrittenPathname', invokePathnameInfo.pathname)
15331537
}
15341538
const normalizeResult = normalizeLocalePath(
@@ -2090,9 +2094,7 @@ export default abstract class Server<
20902094
)
20912095
const actualHash =
20922096
getRequestMeta(req, 'cacheBustingSearchParam') ??
2093-
new URL(req.url || '', 'http://localhost').searchParams.get(
2094-
NEXT_RSC_UNION_QUERY
2095-
)
2097+
getQueryParamFromUrl(req.url, NEXT_RSC_UNION_QUERY)
20962098

20972099
if (expectedHash !== actualHash) {
20982100
// The hash sent by the client does not match the expected value.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { getPathnameFromUrl, getQueryParamFromUrl } from './url-string-utils'
2+
3+
describe('getPathnameFromUrl', () => {
4+
it('returns pathname from a simple path', () => {
5+
expect(getPathnameFromUrl('/foo/bar')).toBe('/foo/bar')
6+
})
7+
8+
it('strips query string', () => {
9+
expect(getPathnameFromUrl('/foo?a=1&b=2')).toBe('/foo')
10+
})
11+
12+
it('strips hash fragment', () => {
13+
expect(getPathnameFromUrl('/foo#section')).toBe('/foo')
14+
})
15+
16+
it('strips both query and hash', () => {
17+
expect(getPathnameFromUrl('/foo?a=1#section')).toBe('/foo')
18+
})
19+
20+
it('handles hash before query', () => {
21+
expect(getPathnameFromUrl('/foo#section?a=1')).toBe('/foo')
22+
})
23+
24+
it('returns "/" for empty string', () => {
25+
expect(getPathnameFromUrl('')).toBe('/')
26+
})
27+
28+
it('returns "/" for undefined', () => {
29+
expect(getPathnameFromUrl(undefined)).toBe('/')
30+
})
31+
32+
it('returns "/" for root path', () => {
33+
expect(getPathnameFromUrl('/')).toBe('/')
34+
})
35+
36+
it('handles root with query', () => {
37+
expect(getPathnameFromUrl('/?_rsc=abc')).toBe('/')
38+
})
39+
})
40+
41+
describe('getQueryParamFromUrl', () => {
42+
it('returns the value of a query parameter', () => {
43+
expect(getQueryParamFromUrl('/path?_rsc=abc123', '_rsc')).toBe('abc123')
44+
})
45+
46+
it('returns the first occurrence when param appears multiple times', () => {
47+
expect(getQueryParamFromUrl('/path?a=1&a=2', 'a')).toBe('1')
48+
})
49+
50+
it('returns null when param is absent', () => {
51+
expect(getQueryParamFromUrl('/path?other=1', '_rsc')).toBeNull()
52+
})
53+
54+
it('returns null when there is no query string', () => {
55+
expect(getQueryParamFromUrl('/path', '_rsc')).toBeNull()
56+
})
57+
58+
it('returns null for undefined url', () => {
59+
expect(getQueryParamFromUrl(undefined, '_rsc')).toBeNull()
60+
})
61+
62+
it('returns empty string for empty value', () => {
63+
expect(getQueryParamFromUrl('/path?_rsc=', '_rsc')).toBe('')
64+
})
65+
66+
it('handles param at the end of query string', () => {
67+
expect(getQueryParamFromUrl('/path?a=1&_rsc=xyz', '_rsc')).toBe('xyz')
68+
})
69+
70+
it('does not match partial param names', () => {
71+
// "x_rsc=bad" should not match "_rsc"
72+
expect(getQueryParamFromUrl('/path?x_rsc=bad&_rsc=good', '_rsc')).toBe(
73+
'good'
74+
)
75+
})
76+
77+
it('decodes percent-encoded values', () => {
78+
expect(getQueryParamFromUrl('/path?q=hello%20world', 'q')).toBe(
79+
'hello world'
80+
)
81+
})
82+
83+
it('stops at hash fragment', () => {
84+
expect(getQueryParamFromUrl('/path?a=1#hash', 'a')).toBe('1')
85+
})
86+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Lightweight URL string utilities that avoid `new URL()` construction.
3+
*
4+
* In hot server paths, `new URL(req.url, 'http://n')` shows up as significant
5+
* self-time because it must parse a full spec-compliant URL. When we only need
6+
* the pathname or a single query-parameter from a *relative* URL such as
7+
* `/path?query=1`, simple string operations are 5-10x cheaper.
8+
*
9+
* Reference: https://tanstack.com/blog/tanstack-start-5x-faster-ssr
10+
*/
11+
12+
/**
13+
* Extract the pathname from a relative URL string (e.g. `/path?q=1` -> `/path`).
14+
* Returns `'/'` for empty/undefined input.
15+
*/
16+
export function getPathnameFromUrl(url: string | undefined): string {
17+
if (!url) return '/'
18+
const qIdx = url.indexOf('?')
19+
const hIdx = url.indexOf('#')
20+
const end =
21+
qIdx >= 0
22+
? hIdx >= 0
23+
? Math.min(qIdx, hIdx)
24+
: qIdx
25+
: hIdx >= 0
26+
? hIdx
27+
: url.length
28+
return url.substring(0, end) || '/'
29+
}
30+
31+
/**
32+
* Get the value of a single query parameter from a URL string without
33+
* constructing a URL or URLSearchParams object.
34+
*
35+
* Only returns the *first* occurrence. Returns `null` when the param is absent.
36+
*/
37+
export function getQueryParamFromUrl(
38+
url: string | undefined,
39+
param: string
40+
): string | null {
41+
if (!url) return null
42+
const qIdx = url.indexOf('?')
43+
if (qIdx < 0) return null
44+
45+
const search = url.substring(qIdx + 1)
46+
const target = param + '='
47+
48+
// Walk through the search string looking for the param
49+
let start = 0
50+
while (start <= search.length) {
51+
const idx = search.indexOf(target, start)
52+
if (idx < 0) return null
53+
54+
// Make sure we matched at a parameter boundary (start of string or after '&')
55+
if (idx === 0 || search.charCodeAt(idx - 1) === 38 /* '&' */) {
56+
const valueStart = idx + target.length
57+
const ampIdx = search.indexOf('&', valueStart)
58+
const hashIdx = search.indexOf('#', valueStart)
59+
const end =
60+
ampIdx >= 0
61+
? hashIdx >= 0
62+
? Math.min(ampIdx, hashIdx)
63+
: ampIdx
64+
: hashIdx >= 0
65+
? hashIdx
66+
: search.length
67+
return decodeURIComponent(search.substring(valueStart, end))
68+
}
69+
70+
// Not at a boundary, keep searching
71+
start = idx + 1
72+
}
73+
74+
return null
75+
}

0 commit comments

Comments
 (0)