The short answer
For an API consumed by programmatic clients, handle mode (accepting both spellings) minimizes client errors and cannot produce redirect loops. For browser-facing resources where a single canonical URL matters, redirect mode is the conventional choice — but use it cautiously on write endpoints, because redirects can break non-idempotent requests. Whatever you choose, enforce the convention in exactly one layer of your stack.
What the two modes actually do
Confirmed baseline: Ktor Server 2.x does not normalize trailing slashes by default. A route declared as /items will not match /items/ unless you opt in by installing the trailing-slash plugin. Once installed, it offers two behaviors:
| Redirect mode | Handle mode |
| Non-canonical request | Responds with a redirect to the canonical path | Served by the same handler directly (200) |
| URL count per endpoint | One canonical URL | Two equivalent URLs |
| Client round trips | Extra round trip on the wrong spelling | None |
| POST/PUT safety | Risky — some clients drop the body or downgrade the method across redirects | Safe — no redirect involved |
| Caches, analytics, OpenAPI docs | Clean, single entry | Split unless you document only the canonical form |
One caveat on redirect mode: a 301 is cached aggressively by browsers and proxies, so changing your canonical convention later is hard to undo. If you redirect write endpoints, prefer a method-preserving status such as 308 over 301/302.
Nested and versioned route groups
The plugin operates on the full resolved path, so in a structure like route("/v1") { route("/items") { ... } } the normalization applies uniformly to /v1/items vs /v1/items/ — there is no per-group configuration to reconcile. The genuine ambiguity point is the group root itself: an empty child path inside route("/v1") is exactly where /v1 vs /v1/ diverges. Define group roots consistently (pick one spelling and use it everywhere) rather than relying on the plugin to paper over inconsistent declarations.
Avoiding redirect loops
The plugin alone is unlikely to loop; loops almost always come from a second layer — an nginx rewrite rule, a CDN, or another middleware — rewriting slashes with the opposite convention. The fix is architectural, not configurational: choose one canonical form (no trailing slash is the common API convention) and enforce it in exactly one place. Verify end-to-end with curl that only one layer emits a Location header and that it never points back to the original spelling.
Recommendation for this case
Given that the concern is API consistency and client error handling for versioned services: use handle mode (or simply register both spellings) and document only the canonical form in your OpenAPI spec. You get zero redirect-related client failures, no loop risk, and consistent docs. Reserve redirect mode for browser-facing GET resources.
Verify before shipping
- In a test
ApplicationEngine, register /v1/items, install the plugin in each mode, and assert the responses for both /v1/items and /v1/items/: redirect status plus Location header in redirect mode, direct 200 in handle mode.
- Check the Ktor API reference for your exact minor version — the plugin's artifact, package, and mode names differ between 1.x and 2.x and may shift again in 3.x.
- If a proxy fronts the server, curl both spellings end-to-end and confirm only one layer rewrites.
- If you do use redirect mode, POST to the non-canonical path with your real client library and confirm the method and body survive.
Two flags on the question itself: the claim that engine threading models interact with path parameter resolution is not documented behavior of this plugin — treat it as unverified unless you can reproduce it. And note that handle mode does not rewrite the request URL, so any downstream code inspecting the raw path still sees whichever variant the client sent.