Skip to content

🔥 feat: add SkipUnmatchedRoutes with two-tier 404/405 fast path - #4486

Merged
ReneWerner87 merged 24 commits into
mainfrom
claude/fiber-404-performance-q0qh6o
Jul 3, 2026
Merged

ReneWerner87 merged 24 commits into
mainfrom
claude/fiber-404-performance-q0qh6o

Conversation

@gaby

@gaby gaby commented Jun 30, 2026

Copy link
Copy Markdown
Member

Adds a SkipUnmatchedRoutes config option that answers requests to unregistered paths with 404 (or 405 when the path exists for other methods) before the middleware chain runs, so work is not spent on bots, scanners, and bad URLs.

Rather than a single full lookahead pass over the route tree on every request (which would duplicate next()'s matching work and regress every matched route), the existence check is split into two tiers built at startup in buildTree:

  • staticRouteMethods: an O(1) map from a static endpoint's prettified path to a bitmask of methods. Static hits run the normal chain with just one map lookup, and unmatched paths in buckets with no parametric/root/star/mount routes resolve to 404/405 with no scanning.
  • bucketParamMethods: a per-tree-bucket bitmask used to decide when the static index is authoritative versus when a parametric scan is needed.

Only when a bucket actually contains parametric routes does a scan run, and it scans only the parametric subset, handing the matched index to next()/nextCustom() via ctx.firstMatchIndex so the endpoints already ruled out are not re-checked. The matched route is still re-matched in
next() so params are recomputed correctly even if parametric middleware ran in between. firstMatchIndex defaults to -1, making the hot path inert when the option is disabled.

Benchmarks (4-core):

  • static matched +12ns (~239->251),
  • parametric matched +82ns (~357->439)
  • unmatched -134ns (~368->234).

The option is off by default and has no effect on existing behavior.

Replaces #4411
Fixes #4403

claude added 2 commits June 30, 2026 04:17
Adds a SkipUnmatchedRoutes config option that answers requests to
unregistered paths with 404 (or 405 when the path exists for other
methods) before the middleware chain runs, so work is not spent on bots,
scanners, and bad URLs.

Rather than a single full lookahead pass over the route tree on every
request (which would duplicate next()'s matching work and regress every
matched route), the existence check is split into two tiers built at
startup in buildTree:

- staticRouteMethods: an O(1) map from a static endpoint's prettified
  path to a bitmask of methods. Static hits run the normal chain with
  just one map lookup, and unmatched paths in buckets with no
  parametric/root/star/mount routes resolve to 404/405 with no scanning.
- bucketParamMethods: a per-tree-bucket bitmask used to decide when the
  static index is authoritative versus when a parametric scan is needed.

Only when a bucket actually contains parametric routes does a scan run,
and it scans only the parametric subset, handing the matched index to
next()/nextCustom() via ctx.firstMatchIndex so the endpoints already
ruled out are not re-checked. The matched route is still re-matched in
next() so params are recomputed correctly even if parametric middleware
ran in between. firstMatchIndex defaults to -1, making the hot path
inert when the option is disabled.

Benchmarks (4-core): static matched +12ns (~239->251), parametric
matched +82ns (~357->439), unmatched -134ns (~368->234). The option is
off by default and has no effect on existing behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Follow-up to the SkipUnmatchedRoutes two-tier fast path.

- Precompute, per method and tree bucket, only the parametric/root/star
  endpoints together with their bucket index (App.paramRoutes). The tier-2
  scan and the cross-method 405 scan now iterate just those candidates
  instead of walking the whole bucket and branch-skipping middleware and
  static routes. This cuts the parametric-matched overhead from roughly
  +23% to +3% over baseline (the example GET bucket had 31 routes, only 19
  of them parametric). The candidate index is resolved against the same
  bucket next() iterates, so firstMatchIndex stays correct.
- Gate the lookahead on whether any middleware is registered
  (App.skipHasUseRoutes). With no middleware the lookahead is pure
  duplicated work because next() already answers 404/405 without running
  anything before the endpoint; behavior is unchanged, just routed through
  next().
- Document the 63-method bitmask limit on the config option.
- Expand tests to ~100% coverage of the new code: custom ErrorHandler ->
  500, mixed static/param cross-method 405, bucket-0 fallback, next()
  skip-before guard, optional params, constraint routes, HEAD autoHead on
  a param route, group middleware skipping, and the no-middleware gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
@gaby
gaby requested a review from a team as a code owner June 30, 2026 13:24
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a SkipUnmatchedRoutes config option to Fiber. When enabled, requests with no matching route receive an immediate 404 or 405 before middleware executes. The change adds route lookup indexes, a firstMatchIndex context path, request-handler lookahead, and matching tests.

Changes

SkipUnmatchedRoutes fast-path

Layer / File(s) Summary
Config option and App state fields
app.go, docs/api/fiber.md
Adds SkipUnmatchedRoutes to Config, adds lookup/index fields to App, and documents the option in the API reference.
Context firstMatchIndex state and invalidation
ctx.go, ctx_interface.go, ctx_interface_gen.go, req.go
Adds firstMatchIndex to DefaultCtx, initializes and invalidates it across request/path changes, exposes accessors, and clears stale lookahead when the request method changes. Updates CustomCtx and Ctx interfaces.
Lookup indexes and skip resolution
router.go
Adds indexedRoute and buildSkipIndexes, rebuilds the lookup indexes from buildTree, resolves 404/405 outcomes with Allow data, and skips earlier non-use routes when a firstMatchIndex is present.
Routing tests and benchmarks
router_test.go
Adds skip-mode routing tests, middleware and custom-context coverage, method-override invalidation checks, parity checks, and benchmarks for unmatched, matched, and 405 request paths.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • gofiber/fiber#3261: Modifies router request entrypoints and routing flow in the same area extended here with skip-unmatched lookahead.
  • gofiber/fiber#3846: Also changes next/nextCustom route-skipping control flow, making it closely related to the skip-unmatched path here.
  • gofiber/fiber#4426: Touches the same non-use route-skipping decision logic in router traversal.

Suggested labels

⚡️ Performance

Suggested reviewers

  • sixcolors
  • efectn
  • ReneWerner87

🐇 I hopped past the long middleware line,
With bitmasks bright and routes aligned.
A 404 or 405 now comes with a wink,
Before the chain can even think.
Fast-path paws on the routing vine!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 49.02% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the new SkipUnmatchedRoutes 404/405 fast path.
Description check ✅ Passed The description clearly explains the feature, implementation, benchmarks, issue reference, and examples, with only minor template omissions.
Linked Issues check ✅ Passed The changes implement #4403 by skipping prefix/group middleware on unmatched routes while preserving 404/405 behavior and backward compatibility.
Out of Scope Changes check ✅ Passed The added docs, tests, benchmarks, and routing internals all support the SkipUnmatchedRoutes feature and are in scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fiber-404-performance-q0qh6o

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ReneWerner87 ReneWerner87 added this to v3 Jun 30, 2026
@ReneWerner87 ReneWerner87 added this to the v3 milestone Jun 30, 2026
@gaby gaby changed the title feat: add SkipUnmatchedRoutes with two-tier 404/405 fast path 🔥 feat: add SkipUnmatchedRoutes with two-tier 404/405 fast path Jun 30, 2026
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.72193% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.97%. Comparing base (0b57242) to head (ec7f3f8).

Files with missing lines Patch % Lines
router_skip.go 95.32% 3 Missing and 2 partials ⚠️
router.go 95.58% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4486      +/-   ##
==========================================
+ Coverage   92.96%   92.97%   +0.01%     
==========================================
  Files         138      139       +1     
  Lines       13609    13789     +180     
==========================================
+ Hits        12651    12820     +169     
- Misses        592      601       +9     
- Partials      366      368       +2     
Flag Coverage Δ
unittests 92.97% <95.72%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app.go`:
- Around line 117-121: The SkipUnmatchedRoutes bitmask handling in app.go needs
to respect the actual integer width used by staticRouteMethods and
bucketParamMethods. Update the logic around RequestMethods and the mask-building
code so the number of methods is capped or validated against the platform bit
width, or switch the masks to a fixed-width unsigned type and adjust the related
comment/doc in app.go accordingly.

In `@router_skip_test.go`:
- Line 10: The router skip tests are triggering httpNoBody lint failures because
several httptest.NewRequest calls in router_skip_test.go pass nil as the body.
Update each of the affected test cases to use http.NoBody instead of nil, and
add the net/http import alongside net/http/httptest so the http.NoBody reference
resolves; the changes should be made wherever these NewRequest calls appear in
the test file.
- Around line 352-365: The new subtests in router_skip_test.go are missing
t.Parallel() and currently share mutable state through app and called, which
makes them unsafe to run concurrently. Update the matched_runs_group_mw and
unmatched_skips_group_mw subtests to call t.Parallel() at the start, and move
any route/app setup and called initialization into each subtest so they are
fully isolated; use the existing test helpers and the app/called variables to
locate the shared setup that needs to be duplicated.

In `@router.go`:
- Line 465: The resolveSkip function signature uses unnamed return values, which
triggers the unnamedResult lint check; update resolveSkip on App to name its
three int results directly in the function signature and keep the existing
return behavior unchanged. Use the resolveSkip method as the target for the
signature change so the linter sees explicit result names.
- Around line 554-569: Reset ctx.firstMatchIndex in the Method(...) override
path so any earlier skip lookahead is discarded when the request method changes.
The issue is in the routing flow around app.resolveSkip and next(), where the
cached firstMatchIndex can be reused against the wrong method bucket after
Method(...) mutates the method; fix it by clearing that field in the same place
Path(...) and RestartRouting() already do, using the Method override logic and
ctx.firstMatchIndex as the key symbols to locate the change.
- Around line 466-467: The method bitmask logic in router.go is still using
int-based shifts in the route lookup path, so add a runtime guard in the
RequestMethods handling to enforce the documented maximum number of methods
before computing methodBit/staticMask. Update the relevant routing/method lookup
code around the bitmask construction and any Allow/SkipUnmatchedRoutes mask
usage to either validate the limit and fail fast, or switch the masks to a
fixed-width unsigned type consistently so oversized method lists cannot silently
corrupt lookups.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1796c339-4503-4ee6-af91-2d5fc34e1e28

📥 Commits

Reviewing files that changed from the base of the PR and between 3f26054 and 2d7b871.

📒 Files selected for processing (7)
  • app.go
  • ctx.go
  • ctx_interface.go
  • ctx_interface_gen.go
  • docs/api/fiber.md
  • router.go
  • router_skip_test.go

Comment thread app.go Outdated
Comment thread router_skip_test.go Outdated
Comment thread router_skip_test.go Outdated
Comment thread router.go Outdated
Comment thread router.go Outdated
Comment thread router.go Outdated
claude and others added 2 commits June 30, 2026 13:37
- resolveSkip returns a named skipResult struct instead of three bare
  ints, resolving the gocritic unnamedResult finding without tripping
  nonamedreturns.
- Reorder the App struct so skipHasUseRoutes groups with the other
  trailing bool fields, fixing the govet fieldalignment finding.
- Move all SkipUnmatchedRoutes tests and benchmarks from
  router_skip_test.go into router_test.go, switch request bodies from nil
  to http.NoBody (gocritic httpNoBody), and flatten the group-middleware
  test so it no longer has order-dependent non-parallel subtests
  (tparallel).

golangci-lint v2.12.2 now reports 0 issues; full suite passes; new-code
coverage remains ~100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
router_test.go (1)

3164-3229: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a one-time correctness assertion in the benchmarks.

The three benchmarks measure throughput but never verify the handler produced the intended outcome (200 for the matched/param cases, 404 for the unmatched case). A silent routing regression would still run "successfully" and report misleading parity numbers. A single sanity check before b.Loop() cheaply protects the benchmark's intent.

♻️ Example for the matched benchmark
 		c := &fasthttp.RequestCtx{}
 		c.Request.Header.SetMethod(MethodGet)
 		c.URI().SetPath("/user/repos") // genuinely static route
+
+		// Sanity check: ensure the path actually matches before measuring.
+		appHandler(c)
+		require.Equal(b, StatusOK, c.Response.StatusCode())
 
 		b.ReportAllocs()
 		b.ResetTimer()
 		for b.Loop() {
 			appHandler(c)
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router_test.go` around lines 3164 - 3229, Add a one-time correctness sanity
check to each benchmark in Benchmark_SkipUnmatchedRoutes_Matched,
Benchmark_SkipUnmatchedRoutes_MatchedParam, and
Benchmark_SkipUnmatchedRoutes_Unmatched so the benchmark verifies the handler
outcome before measuring throughput. After calling app.Handler() and before
b.Loop(), invoke the handler once on the prepared fasthttp.RequestCtx and assert
the expected status for the route type (200 for the matched and matched-param
cases, 404 for the unmatched case). Keep the check lightweight and local to the
existing run helper so the benchmark still measures only steady-state
performance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@router_test.go`:
- Around line 3164-3229: Add a one-time correctness sanity check to each
benchmark in Benchmark_SkipUnmatchedRoutes_Matched,
Benchmark_SkipUnmatchedRoutes_MatchedParam, and
Benchmark_SkipUnmatchedRoutes_Unmatched so the benchmark verifies the handler
outcome before measuring throughput. After calling app.Handler() and before
b.Loop(), invoke the handler once on the prepared fasthttp.RequestCtx and assert
the expected status for the route type (200 for the matched and matched-param
cases, 404 for the unmatched case). Keep the check lightweight and local to the
existing run helper so the benchmark still measures only steady-state
performance.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0dbf62d9-f338-49d0-b67c-9801bd7ce5b6

📥 Commits

Reviewing files that changed from the base of the PR and between 2d7b871 and e0d6034.

📒 Files selected for processing (3)
  • app.go
  • router.go
  • router_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • router.go
  • app.go

- Switch the SkipUnmatchedRoutes method bitmasks (staticRouteMethods,
  bucketParamMethods, skipResult.allowMask, emitSkip) from int to uint64
  so they stay 64-bit wide on 32-bit builds, matching the documented
  method-count limit. Updated the config doc note accordingly.
- Reset ctx.firstMatchIndex when Method(override) changes the request
  method, mirroring Path() and RestartRouting(). Without this, a method
  switch mid-chain could reuse a lookahead index computed against the
  previous method's tree bucket and skip valid routes in the new bucket.
  Added a regression test that fails without the reset.

golangci-lint v2.12.2 clean; full suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Replace the ad-hoc skip benchmarks with the full benchmark set from
PR #4411 (Unmatched, Matched, 405_Middleware, 405_NMiddleware, Deep)
verbatim, so this implementation and the original can be compared on
identical benchmark code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
router_test.go (2)

3164-3330: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Repetitive without_skip/with_skip setup across four benchmarks.

Each pair (Unmatched, Matched, 405_Middleware, 405_NMiddleware) duplicates nearly identical app/middleware/RequestCtx setup, differing only in Config.SkipUnmatchedRoutes and middleware count. A small helper (e.g. newSkipBenchApp(skip bool, middlewares int) (*fasthttp.RequestHandler, *fasthttp.RequestCtx)) parameterized by method/path would remove the duplication while keeping each benchmark isolated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router_test.go` around lines 3164 - 3330, The four SkipUnmatchedRoutes
benchmarks duplicate the same app, middleware, and RequestCtx setup in both
without_skip and with_skip branches. Refactor the repeated setup in the
benchmark functions Benchmark_SkipUnmatchedRoutes_Unmatched,
Benchmark_SkipUnmatchedRoutes_Matched,
Benchmark_SkipUnmatchedRoutes_405_Middleware, and
Benchmark_SkipUnmatchedRoutes_405_NMiddleware into a small helper that accepts
SkipUnmatchedRoutes, middleware count, method, and path, then returns the
configured app handler and request context so each benchmark stays isolated but
concise.

3333-3350: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

status field in deepScenarios is never asserted, and one scenario name has a typo.

status is populated per scenario but never read inside the benchmark loop (lines 3382-3386), so it currently serves only as documentation. Asserting it once during setup (before b.ResetTimer()) would let this table double as a regression guard if a future routing change silently changes the matched status. Separately, "Unmatched_Top_level_SBucket_WwongMethod" appears to be a typo for "WrongMethod".

💚 Proposed addition to validate `status` before timing starts
 				appHandler := app.Handler()
 
 				c := &fasthttp.RequestCtx{}
 				c.Request.Header.SetMethod(s.method)
 				c.URI().SetPath(s.path)
 
+				appHandler(c)
+				if c.Response.StatusCode() != s.status {
+					b.Fatalf("expected status %d, got %d", s.status, c.Response.StatusCode())
+				}
+
 				b.ReportAllocs()
 				b.ResetTimer()
 				for b.Loop() {
 					appHandler(c)
 				}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router_test.go` around lines 3333 - 3350, The deepScenarios table in
router_test.go includes a status field that is never checked and a scenario name
with a typo. Update the benchmark setup in the deep routing test so each
scenario’s expected status is asserted before b.ResetTimer(), using the existing
deepScenarios entries to validate the response once during setup. Also rename
the mislabeled scenario from “Unmatched_Top_level_SBucket_WwongMethod” to
“Unmatched_Top_level_SBucket_WrongMethod” to keep the test case name accurate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@router_test.go`:
- Around line 3164-3330: The four SkipUnmatchedRoutes benchmarks duplicate the
same app, middleware, and RequestCtx setup in both without_skip and with_skip
branches. Refactor the repeated setup in the benchmark functions
Benchmark_SkipUnmatchedRoutes_Unmatched, Benchmark_SkipUnmatchedRoutes_Matched,
Benchmark_SkipUnmatchedRoutes_405_Middleware, and
Benchmark_SkipUnmatchedRoutes_405_NMiddleware into a small helper that accepts
SkipUnmatchedRoutes, middleware count, method, and path, then returns the
configured app handler and request context so each benchmark stays isolated but
concise.
- Around line 3333-3350: The deepScenarios table in router_test.go includes a
status field that is never checked and a scenario name with a typo. Update the
benchmark setup in the deep routing test so each scenario’s expected status is
asserted before b.ResetTimer(), using the existing deepScenarios entries to
validate the response once during setup. Also rename the mislabeled scenario
from “Unmatched_Top_level_SBucket_WwongMethod” to
“Unmatched_Top_level_SBucket_WrongMethod” to keep the test case name accurate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b06f04bb-5375-45cb-bb59-6872ff43f0af

📥 Commits

Reviewing files that changed from the base of the PR and between 60822fc and 338b719.

📒 Files selected for processing (1)
  • router_test.go

claude added 4 commits June 30, 2026 23:23
emitSkip took the Ctx interface, so the variadic c.Append(HeaderAllow,
method) calls escaped to the heap (one alloc per allowed method) on 405
responses. Take the concrete *DefaultCtx in emitSkip so the variadic
stays on the stack (0 allocs, matching next()'s own 405 path), and add
emitSkipCustom for the rarer custom-context handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Profiling the deep parametric with_skip path showed resolveSkip spending
most of its non-match time on redundant map lookups that the static fast
path doesn't need:

- Replace the per-bucket bucketParamMethods bitmask (two int-map lookups
  per request) with a single app-level skipHasDynamicRoutes bool for the
  authoritative-miss check.
- Give paramRoutes an entry for every treeStack bucket so its lookup ok
  result mirrors treeStack existence, folding away the separate
  treeStack[methodInt][treeHash] existence check.

The static fast path (tier 1a) is untouched, so the static-route win is
preserved. Deep parametric Matched/with_skip drops ~8% (194ns -> 179ns);
the only remaining overhead vs a no-static-index scan is the static map
lookup itself, which is what buys the static win.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Reworks the previous attempt (an app-level skipHasDynamicRoutes bool),
which regressed unmatched 404s by losing the per-bucket short-circuit,
and could also mis-resolve 405 vs 404.

resolveSkip now:
- looks up this method's candidates once (paramRoutes has an entry for
  every treeStack bucket, so its ok result drives the bucket-0 fallback,
  removing the separate treeStack existence check), and
- scans those candidates BEFORE the authoritative-miss check, so a
  matched request never pays for it, while
- the authoritative miss uses bucketParamMethods[treeHash] |
  bucketParamMethods[0] (restored), which correctly accounts for other
  methods whose match would live in the treeHash bucket or fall back to
  bucket 0 — fixing a potential 404-instead-of-405 in the prior bucketKey
  approach.

Net vs PR #4411 on its own benchmark suite: parametric Matched and
405_Middleware are now faster, deep parametric Matched is much improved
(+22% -> ~+10%), and unmatched is back to ~parity. The static fast path
(tier 1a) is untouched, so the static-route win is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
On a parametric match the lookahead already computes the route's params
into c.values; next() then re-matched the same route (a second getMatch),
which dominated deep parametric with_skip cost. next()/nextCustom() now
reuse those params and invoke the handler directly when the request has
reached the pre-resolved endpoint, guarded by skipHasParamUse: the reuse
only fires when no parametric/wildcard middleware could have overwritten
c.values between the lookahead and the endpoint (otherwise it falls back
to re-matching). The static fast path never sets firstMatchIndex, so it
is unaffected.

Matched/with_skip ~385ns -> ~358ns (now faster than the pre-lookahead
scan). Added tests for multi-param reuse correctness and a parametric-
middleware clobber guard (verified to fail without the guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Copilot AI review requested due to automatic review settings July 1, 2026 00:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in SkipUnmatchedRoutes router fast path to short-circuit unmatched requests (404 / 405) before middleware execution, aiming to reduce wasted work on bot/scanner traffic while keeping matched-route overhead low.

Changes:

  • Introduces two-tier route-existence indexes built at startup (staticRouteMethods, bucketParamMethods, paramRoutes) and a resolveSkip / emitSkip short-circuit path.
  • Plumbs firstMatchIndex through ctx/routing to avoid re-checking endpoints already ruled out by the lookahead (and optionally reuse params when safe).
  • Adds extensive tests/benchmarks and documents the new config option.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
router.go Implements SkipUnmatchedRoutes lookahead/indexing, integrates fast path into request handlers, and adds firstMatchIndex endpoint-skipping logic.
router_test.go Adds correctness tests and benchmarks for SkipUnmatchedRoutes behavior/perf across static/param/mount/custom ctx cases.
req.go Invalidates firstMatchIndex when Method(...) overrides the request method.
ctx.go Stores/invalidates firstMatchIndex across Reset/Path/RestartRouting and exposes getters/setters for custom ctx plumbing.
ctx_interface.go Extends CustomCtx internal-method set with firstMatchIndex accessors.
ctx_interface_gen.go Extends generated Ctx interface with firstMatchIndex accessors.
app.go Adds router indexes/state fields to App and adds Config.SkipUnmatchedRoutes.
docs/api/fiber.md Documents SkipUnmatchedRoutes in the public config table.
Files not reviewed (1)
  • ctx_interface_gen.go: Generated file

Comment thread router.go Outdated
Comment thread router.go Outdated
Comment thread ctx_interface.go
Comment thread router.go Outdated
Comment thread router_test.go Outdated
- Move the flash-cookie parse/clear above the SkipUnmatchedRoutes
  short-circuit in both request handlers, so flash messages are still
  cleared on a skipped 404/405 (matches behavior when the option is off).
- Guard buildSkipIndexes against RequestMethods > 64: the per-method
  masks are 64-bit, so beyond 64 methods the fast path is disabled and
  requests fall through to the normal router (which still returns the
  correct 404/405). Documented on the config option; added a test.
- Fix benchmark case name typo WwongMethod -> WrongMethod.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.

Benchmark suite Current: ec7f3f8 Previous: 0b57242 Ratio
Benchmark_Compress/Zstd (github.com/gofiber/fiber/v3/middleware/compress) - B/op 1 B/op 0 B/op +∞

This comment was automatically generated by workflow using github-action-benchmark.

claude and others added 11 commits July 1, 2026 04:14
The route-matching loop re-read loop-invariant state on every iteration.
For next() these are struct fields (firstMatchIndex, shouldSkipNonUseRoutes,
skipHasParamUse) that the compiler reloads each iteration because c is a
pointer aliased by route.match(&c.values); hoisting them into locals is
neutral-to-slightly-positive.

For nextCustom the same values plus the request path/values were fetched
through interface accessors (getDetectionPath, Path, getValues,
getFirstMatchIndex, getSkipNonUseRoutes) once per iterated route. Hoisting
them removes ~5 interface calls per bucket entry.

Benchmark_Router_HandlerCustom (new, custom-context request over a deep
bucket): 620ns -> 377ns (-39%), 0 allocs. Default-ctx routing benchmarks
are unchanged. None of these values are mutated during the loop (path/method
changes re-enter via RestartRouting), so the hoist is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
…hmark

The custom-context 404/405 cross-method scan called getDetectionPath/
Path/getValues through the interface on every route of every method's
bucket. Reuse the locals already hoisted at the top of nextCustom.

Benchmark_Router_HandlerCustom_NotFound (new): -2.45% on the custom 404
path, 0 allocs. Completes the accessor-hoisting started in the previous
commit for the matched path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
The cross-method fallback in next()/nextCustom() scanned every configured
RequestMethod on each 404/405, doing up to two tree-bucket map lookups per
method even for methods with no registered routes. Profiling the 404 path
showed mapaccess_fast64 accounting for ~17% of samples.

Precompute app.routeMethods, a bitmask of methods that own at least one
non-use route (built in buildTree), and skip methods whose bit is clear
before touching their buckets. Such methods can never contribute an Allow
entry, so the result — 404 vs 405 and the Allow set — is unchanged. The
mask is trusted only when RequestMethods fits in 64 bits (methodMaskValid);
otherwise the fallback scans every method as before.

Measured (benchstat, n=10): custom-ctx 404 -7.5%, unmatched 404 -2.5%,
405 (N middleware) -2.5%; matched paths and allocations unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
configDependentPaths runs on every request and, for the default
case-insensitive configuration, copied c.path into c.detectionPath and
then lowercased the copy in a second pass (append + UnsafeToLower). Since
UnsafeToLower is a table-lookup loop rather than a SIMD pass, the copy is
redundant work.

Add appendLowerASCII, which writes the lowercased bytes straight from the
source into the destination buffer in a single pass, reusing the buffer's
backing array. The case-sensitive branch keeps the plain copy. Behavior is
identical: only ASCII A-Z fold, bytes >= 0x80 pass through unchanged.

Measured (benchstat, n=12): unmatched -2.1%, matched trending down, custom
neutral; zero allocations. Covered by Test_appendLowerASCII.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
getMatch advanced both detectionPath and path by the same amount on every
segment, but path is only read at parameter segments (path[:i]). Re-slicing
it each iteration is a redundant slice-header write.

Keep path fixed and track a running offset of consumed bytes, reading params
as path[offset:offset+i]. detectionPath and path advance in lockstep and
detectionPath is at most one trailing-slash byte shorter than path, so
offset+i never exceeds len(path); the captured params are byte-identical.

Measured (benchstat, n=10): parametric-matched custom path -2.75%
(p=0.043), static/chain paths neutral, no parallel regression, zero
allocation change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
getMatch walks parser.segs on every parametric match. The segments were
each allocated separately, so the hot loop chased pointers into scattered
heap locations; profiling attributed a large share of getMatch to the loop
and per-segment field loads.

After parsing, copy the segment values into a single contiguous backing
array and repoint the slice entries at them. The slice type is unchanged
(still []*routeSegment), the segments are read-only after registration, and
this runs once per route at registration — no request-time allocation.

Measured (benchstat, n=12): parametric-matched -4.9% and custom-ctx
parametric -5.1% (both p=0.000), zero allocation change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
Two earlier optimizations caused CI failures and are reverted:

- appendLowerASCII (fused copy+lowercase): triggered the modernize linter
  (rangeint) and reimplemented what gofiber/utils already provides. Restore
  the utils-based UnsafeToLower path in configDependentPaths.
- contiguous-segment packing in parseRoute: allocated a second backing array
  per route at parse time, ~doubling Benchmark_RoutePatternMatch B/op
  (336 -> 624) and Benchmark_Startup_Process B/op, tripping the benchmark
  regression gate. The request-time speed gain is not worth the startup
  allocation cost.

The allocation-neutral getMatch offset change is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
The feature added seven fields to the App struct. Group them into a dedicated
skipRouteIndex struct and move all related logic into a new router_skip.go:

- skipRouteIndex holds the lookahead indexes (staticMethods, bucketParamMethods,
  paramRoutes, hasUseRoutes, hasParamUse) plus the 405-fallback method mask
  (routeMethods, methodMaskValid).
- App now carries a single `skip skipRouteIndex` field instead of seven.
- resolveSkip, emitSkip, emitSkipCustom, buildSkipIndexes, indexedRoute and
  skipResult move to router_skip.go; buildTree just calls app.buildSkipIndexes().

Pure reorganization; no behavior change. Full suite and golangci-lint pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsu9YmVkgPbM6fPYqZKHZP
- replace the triple-map lookahead with a per-bucket skipBucket index
  (paramMask + per-method candidates, bucket-0 fallback materialized at
  build time), one map lookup per unmatched request instead of up to
  2+2N: unmatched 132->118ns, 405 779->710ns, matched unchanged, 0 allocs
- gate the fast path on a single precomputed enabled bool
- rename skipMethodNot to skipNotAllowed and type the decision consts
- fix a wrong invariant comment in getMatch (TrimRight strips all
  trailing slashes, not one byte) and trim comments to the surrounding
  density
- reorder ctx.go setters so go generate is a no-op again
- document that middleware never runs for skipped requests (CORS
  preflight, catch-all 404 pages, static/proxy/healthcheck, loggers);
  add the whats_new entry
- add tests: flash-cookie clearing on skipped 404, Path override
  invalidation, mounted error handler, error sentinels, star-middleware
  re-match, UnescapePath, emitSkipCustom 500 fallback; extend the parity
  test to bodies and Content-Type; consolidate the four copy-paste
  benchmarks into one table-driven benchmark

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ReneWerner87

Copy link
Copy Markdown
Member

Two open questions from reviewing/optimizing this branch (0b526b0, 4c609a3):

  1. Naming: SkipUnmatchedRoutes reads as "skip routes that are unmatched", but nothing route-shaped is skipped; what is skipped is the middleware chain for unmatched requests. 📝 [Proposal]: skip group/prefix middleware when no child route matches #4403 originally proposed SkipMiddlewareOnUnmatched, which is more precise and actively warns about the main sharp edge (middleware never sees these requests). Since v3 is unreleased, the rename would be free right now. Keep or rename?

  2. CORS preflight: with the flag enabled, an OPTIONS request to a path registered only for other methods short-circuits to 405 without CORS headers, before cors.New() ever runs; apps rarely register explicit OPTIONS routes, so enabling the flag breaks preflight for CORS-enabled endpoints. 0b526b0 documents this (Config comment, fiber.md, whats_new.md). Should OPTIONS additionally be exempted from the short-circuit (a single method check before resolveSkip), or is documenting it enough?

Benchmarks (Apple M2 Pro, -cpu=1 -count=10, benchstat medians): 0b526b0 replaces the three-map lookahead with a per-bucket index whose bucket-0 fallback is materialized at build time, so resolveSkip does one map lookup per unmatched request and the cross-method 405 scan does none:

Scenario (350-route fixture) flag off before after
Unmatched (3 middlewares) 190ns 132ns 118ns
405 (3 middlewares) 834ns 779ns 710ns
Matched parametric 242ns 247ns 248ns

All paths stay at 0 allocs/op. With the flag off there is no regression; the always-on 405 method-mask prune makes unmatched/405 requests 8-16% faster than main even then. The four copy-paste Benchmark_SkipUnmatchedRoutes_* functions were consolidated into one table-driven Benchmark_SkipUnmatchedRoutes (Unmatched/Matched/405_Middleware/405_NoMiddleware, each with and without the flag).

@gaby

gaby commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

@ReneWerner87 I prefer SkipUnmatchedRoutes. The other name is too complicated.

The CORS issue is concerning. We need to fix that, since its a weird edge case to introduce just because of this flag.

ReneWerner87 and others added 2 commits July 2, 2026 14:25
SkipUnmatchedRoutes answers unmatched requests with 404/405 before the
middleware chain runs, which broke CORS preflight: an OPTIONS request to a
path registered only for GET/POST was short-circuited to 405 before the
cors middleware could respond, blocking every cross-origin call to that path.

Skip the fast path for CORS preflight requests (reusing the existing
Ctx.IsPreflight(): OPTIONS + Access-Control-Request-Method + Origin) in both
defaultRequestHandler and customRequestHandler, so preflight flows through the
normal chain and cors middleware answers it. Non-preflight OPTIONS still hit
the fast 404/405, preserving the feature's value against bot/scanner traffic.

The guard sits behind app.skip.enabled, so there is no cost when
SkipUnmatchedRoutes is off. When on, non-preflight requests pay one method
lookup plus a length-mismatch string compare (a few ns, 0 allocs).

Also fix a truncated doc comment on skipRouteIndex.staticMethods.

Tests: preflight exemption + non-preflight 405 on both the default- and
custom-context handlers. Docs updated (config comment, fiber.md, whats_new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ReneWerner87

Copy link
Copy Markdown
Member

CORS preflight exemption (ec7f3f8)

Addresses the OPTIONS/CORS-preflight gap noted for SkipUnmatchedRoutes.

Problem: with the option on, an OPTIONS preflight to a path registered only for GET/POST was short-circuited to 405 before the middleware chain ran, so the cors middleware never got to answer it - blocking every cross-origin call to that path.

Fix: both defaultRequestHandler and customRequestHandler now gate the fast path on app.skip.enabled && !ctx.IsPreflight(), reusing the existing Ctx.IsPreflight() (OPTIONS + Access-Control-Request-Method + Origin). Preflight requests flow through the normal chain so cors answers them; non-preflight OPTIONS still hit the fast 404/405, keeping the bot/scanner protection.

Benchmarks (Benchmark_SkipUnmatchedRoutes, Apple M2 Pro, -count=10, benchstat). The guard only runs when the option is on, so without_skip / NoMiddleware are controls that must not move:

scenario base new delta
Unmatched / with_skip 114.0ns 117.5ns +3.1% (p=0.005)
Matched / with_skip 247.6ns 251.5ns ~ (n.s.)
405_Middleware / with_skip 716.2ns 727.4ns +1.6%
405_NoMiddleware / with_skip 816.4ns 818.0ns ~ (n.s.)
Unmatched / without_skip (control) 186.4ns 191.2ns +2.6%
Matched / without_skip (control) 246.9ns 248.0ns ~ (n.s.)

0 allocs/op throughout (unchanged). The without_skip control drifting +2.6% shows ~2-3% run-to-run variance on this machine - that path provably never calls IsPreflight(), so the real added cost is a single method lookup + length-mismatch compare (a few ns) on the with_skip hot path, within the noise band. Zero cost when the option is off (short-circuit on skip.enabled).

Tests: preflight-exemption + non-preflight-405 on both the default- and custom-context handlers. Docs updated (config comment, docs/api/fiber.md, whats_new.md); also fixed a truncated doc comment on skipRouteIndex.staticMethods.

Known limitation: a real cross-origin GET/POST to a fully unregistered path still fast-404s without CORS headers (browser reports a CORS error rather than a 404). The main breakage - preflight failing - is resolved.

@ReneWerner87
ReneWerner87 merged commit adef0e8 into main Jul 3, 2026
21 of 22 checks passed
@ReneWerner87
ReneWerner87 deleted the claude/fiber-404-performance-q0qh6o branch July 3, 2026 08:29
@github-project-automation github-project-automation Bot moved this to Done in v3 Jul 3, 2026
@ReneWerner87 ReneWerner87 modified the milestones: v3, v3.5.0 Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

📝 [Proposal]: skip group/prefix middleware when no child route matches

4 participants