Feature request: Optional bandwidth-aware mode for URLTest
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 38.1k
- Forks
- 4.6k
- Avg merge
- 19d 15h
- Merged PRs (30d)
- 1
Description
Problem
urltest currently ranks outbounds by a single scalar: the time to receive response headers from a HEAD request. This is a good proxy for reachability and for round-trip latency, but it carries no information about sustained throughput.
On congested or shaped paths these two properties decouple sharply. A small, single-round-trip probe can complete quickly on a path whose sustained TCP throughput has collapsed — the probe finishes before the connection ever leaves slow start, so it never observes the degradation. Meanwhile a path with a slightly higher handshake cost may deliver several times the usable bandwidth.
A concrete shape of this, which is not specific to any particular network or region:
- A VLESS + REALITY outbound over TCP completes the 204 probe in ~100 ms and is ranked best.
- Actual page assets, video segments, and downloads over that outbound crawl, because sustained TCP throughput on the path is heavily degraded (congestion, shaping, or per-flow rate limiting that only engages once a flow grows past a few KiB).
- A Hysteria2 outbound over QUIC/UDP probes at ~150 ms — worse by the current metric — but delivers substantially better real-world throughput.
Because tolerance only widens the latency band, and every stored sample is latency, no configuration of the current urltest can express "prefer the outbound that actually moves data." The user's only recourse is to abandon urltest and switch manually via selector, which defeats the purpose of automatic selection.
Current behavior
All references in this section are pinned to the latest stable release, v1.13.16 (commit 17ec3c71, 2026-08-03). I verified that the probe function, the selection function, and the sweep function are byte-identical on the current testing branch and on v1.14.0-beta.8, so everything here applies to the development branch too — only the line numbers shift. Where I refer to code that exists only on the 1.14 line, I say so explicitly and pin to v1.14.0-beta.8.
Probe — common/urltest/urltest.go:
- The request method is
HEAD, againsthttps://www.gstatic.com/generate_204whenurlis empty. - No response body is transferred or consumed.
resp.Body.Close()is called immediately, and aHEADresponse carries no body by definition. The probe therefore never sends more than a handshake plus a request, and never reads more than response headers. - The timer starts before
DialContextand is reset after the dial if the transport needs a handshake before write. The single recorded value istime.Since(start)at the momentclient.Doreturns — i.e. time to response headers, truncated touint16milliseconds. - Per-probe deadline is
C.TCPTimeout= 15s, applied both as thehttp.Client.Timeoutand as the context timeout at the call site. - Multiplexed outbounds are probed twice, and only the second result is kept, so the measurement excludes multiplex session establishment.
Stored metric — adapter/experimental.go#L23-L26:
type URLTestHistory struct {
Time time.Time `json:"time"`
Delay uint16 `json:"delay"`
}
One timestamp, one delay. There is no field in which a throughput observation could be recorded today.
Selection — protocol/group/urltest.go#L287-L329:
- TCP and UDP are selected independently; the currently selected outbound seeds
minDelayso it has incumbency. - A challenger replaces the incumbent only when
minDelay > history.Delay + g.tolerance—tolerance(default 50 ms) is pure latency hysteresis. - Outbounds with no history entry are skipped; if none has history, the first network-compatible outbound is used as a fallback.
- On change,
performUpdateCheckinterrupts existing connections perinterrupt_exist_connections.
Scheduling — interval default 3m, idle_timeout default 30m. Probing is driven by a ticker created lazily on first use and stops once idle beyond idle_timeout, which is an important existing property: urltest already suspends itself when the group is not being used. Probe fan-out is capped at 10 concurrent, and a re-entrancy guard prevents overlapping sweeps.
Schema — option/group.go#L11-L18 — outbounds, url, interval, tolerance, idle_timeout, interrupt_exist_connections. Documented at docs/configuration/outbound/urltest.md; the docs describe tolerance only as "The test tolerance in milliseconds."
Proposed behavior
An opt-in bandwidth-aware probe mode, disabled by default, that supplements the existing latency measurement rather than replacing it. When enabled, the probe for each outbound would:
- Issue a bounded
GETinstead ofHEAD, against a configurable URL that returns a payload of known minimum size. - Record TTFB — time to response headers — using the existing timer semantics, so the current metric is preserved unchanged and remains available.
- Read the response body until a configured byte cap is reached (suggested default in the 64 KiB–256 KiB range, configurable up to ~1 MiB), recording the time spent transferring those bytes.
- Compute effective throughput as
bytes_read / transfer_time, wheretransfer_timeexcludes TTFB, so the value reflects the data phase rather than connection setup. - Cancel early as soon as the byte cap is reached — cancel the request context and close the connection rather than draining the remainder. This is what bounds the cost.
- Store both metrics, extending
URLTestHistorywith optional fields (e.g.Throughput uint32in bytes/sec andBytes uint32actually read, zero when the mode is off), keeping the existingDelayfield's meaning intact for the Clash API and for all existing clients.
When the mode is disabled — the default — the probe path stays byte-for-byte what it is today: a HEAD with no body transfer.
Much of the machinery for this already exists on the 1.14 line. common/networkquality (shipped in v1.14.0-beta.8) already measures download and upload capacity over an arbitrary outbound — NewHTTPClient(dialer N.Dialer) takes a dialer directly, and sing-box tools networkquality --outbound is exactly "measure throughput through a detour." The new common/httpclient package supplies HTTP/1.1, HTTP/2 and HTTP/3 transports. So this proposal is not asking for a new measurement subsystem to be built from scratch — it is asking for a deliberately bounded, cheap variant of a measurement the project already performs, wired in as a periodic selection input. See Alternatives considered for why the existing saturating test cannot be used directly.
Two caveats worth stating up front rather than discovering later:
- A cap in the 64–256 KiB range measures throughput while the flow is still in or near slow start, so the absolute number will understate a fast path's true capacity. That is acceptable and arguably desirable here: the goal is ranking, not benchmarking, and the ratio between a shaped and an unshaped path is already large at that scale. It does mean the value must not be presented to users as a speedtest result.
- Shaping that only engages after several MiB will not be caught by a bounded probe. This proposal targets the common case where degradation is visible within a few hundred KiB; it is explicitly not a general replacement for a real speed test.
Example configuration
{
"type": "urltest",
"tag": "auto",
"outbounds": [
"reality-tcp",
"hysteria2-quic",
"trojan-tcp"
],
"url": "https://www.gstatic.com/generate_204",
"interval": "3m",
"tolerance": 50,
"idle_timeout": "30m",
"interrupt_exist_connections": false,
"bandwidth_test": {
"enabled": true,
"url": "https://speed.cloudflare.com/__down?bytes=1048576",
"max_bytes": "256KiB",
"timeout": "5s",
"interval": "15m",
"concurrency": 2,
"strategy": "throughput_with_latency_floor",
"latency_floor": "400ms",
"throughput_tolerance": "25%"
}
}
Notes on the shape:
bandwidth_testis a nested object so that the whole feature is oneenabled: falseaway from being inert, and so no existing field changes meaning.- A separate
urlis required —generate_204returns no body and cannot serve as a throughput target. - A separate, longer
intervalmatters: the appropriate cadence for a throughput probe is much lower than for a liveness probe. When omitted it should default to a multiple of the latencyinterval, not to the same value. - A separate, lower
concurrencythan the latency sweep's fixed 10, since these probes actually consume bandwidth and running them simultaneously makes them contend with each other and skew every result.
Selection strategies
The ranking rule should be explicit and configurable rather than implicit, because the right trade-off is workload-dependent:
latency— current behavior. Default, unchanged. Throughput is measured (if enabled) and exposed but not used for selection.throughput— rank by effective throughput, withthroughput_toleranceas relative hysteresis (a percentage rather than a millisecond band, since throughput ratios are the meaningful comparison). Latency is ignored beyond liveness.throughput_with_latency_floor— the recommended mode for the motivating case. Discard any outbound whose TTFB exceedslatency_floor, then rank the survivors by throughput. This keeps a pathologically slow-to-connect outbound from winning on bulk transfer alone, which matters for interactive traffic.
Hysteresis deserves particular care. Throughput samples are noisier than latency samples — a single probe landing during a transient burst can swing the value severalfold. Concretely: hysteresis should be relative rather than absolute, the incumbent should retain the same incumbency advantage the current Select gives it, and smoothing across the last N samples (EWMA, or simply the median of the last 3) would prevent the group from oscillating and repeatedly firing interruptGroup.Interrupt. Connection churn from flapping selection would be a real regression, not a cosmetic one.
Whether the throughput metric should also apply to UDP selection is worth deciding explicitly, since TCP and UDP are selected separately today and a QUIC-based outbound's throughput characteristics may differ between the two paths.
Resource considerations
This is the part that most needs to constrain the design, and the reason the feature should be off by default.
Mobile battery. Each probe holds the radio active for the duration of the transfer rather than for a single round trip. With N outbounds this multiplies. Mitigations: a longer default interval for the bandwidth probe than for the latency probe; reuse of the existing idle-suspension mechanism so no throughput probing occurs while the group is unused; and respect for the existing pause.Manager integration so probing halts on device sleep and network pause exactly as latency probing does today.
Metered data. This is a real, user-visible cost. At 256 KiB per outbound with 10 outbounds every 15 minutes, the consumption is roughly 10 MiB/hour, or ~240 MiB/day — enough to matter on a capped plan and enough that it must be documented plainly rather than buried. The byte cap must be a hard cap enforced by the reader, not a hint. Being able to disable bandwidth probing on metered connections (or simply keeping interval conservative by default) should be considered part of the feature, not a follow-up.
CDN and server load. A default probe URL shipped in sing-box would be fetched by a very large number of clients. This argues for: no default bandwidth_test.url at all (require the user to set it, failing closed if enabled is true without one), documentation recommending the user's own endpoint or a service that explicitly permits this use, and a cap low enough that the aggregate is not abusive. #4189 already shows the current latency probe drawing HTTP 429 responses from a shared endpoint; a body-transferring probe would reach that threshold considerably faster.
Memory, especially iOS Network Extension. The iOS NE process runs under a hard ~50 MB jetsam limit, and #3976 documents extension kills specifically triggered by speed-testing traffic through the tunnel. This constrains the implementation directly: read into a single small fixed reusable buffer (e.g. 32 KiB) in a discard loop, never accumulating the payload; never use io.ReadAll or any growing buffer; and keep bandwidth-probe concurrency low so peak in-flight buffers stay bounded. The max_bytes cap must bound bytes read, not bytes retained — retained memory should be O(buffer size), independent of max_bytes. Consider a lower default cap and concurrency on constrained platforms, or leaving the feature off there by default.
Not a speedtest. The design intent is explicitly a ranking signal, not a benchmark. Guardrails: a hard byte cap, a hard per-probe timeout (bandwidth_test.timeout, suggested default well under the current 15s C.TCPTimeout), early cancellation on reaching the cap, and a bandwidth-probe concurrency limit lower than the existing fixed 10. If a probe hits its timeout before the cap, throughput should be computed from bytes actually transferred rather than the sample being discarded — a timeout is itself strong evidence of a slow path.
Idle suspension. The existing lazy-ticker and idle-timeout behavior already provides the right frame; the bandwidth probe should inherit it rather than introduce a second, independent scheduler.
Alternatives considered
- Raise
tolerance. Widens the latency band but still ranks purely on latency; a shaped path with genuinely low TTFB still wins. Does not address the problem. - Repeat the latency probe and take the minimum (#1528, "true delay"). Improves the stability of the latency estimate. A shaped path's TTFB is genuinely low, so repetition confirms the misleading value rather than correcting it.
- HTTP/2 ping health check (#1494). Moves in the opposite direction — a cheaper, purer RTT measurement. Valuable on its own merits and fully complementary to this proposal; it does not surface throughput.
- Reuse the existing
networkqualitytest as the probe. This is the closest existing thing in-tree and deserves a direct answer.sing-box tools networkquality(new inv1.14.0-beta.8) already produces exactly the metric this proposal wants —Result.DownloadCapacity, with an accuracy rating — and already runs over a chosen outbound. It cannot serve as a periodic per-outbound probe, though, because it is deliberately the opposite kind of measurement: it saturates the link, scaling to 16 parallel connections, and runs for up to 20 seconds per invocation. Running that against every outbound in a group every few minutes is precisely the outcome the Resource considerations section argues against — on battery, on metered data, and on iOS NE memory. It is also manually invoked and is referenced nowhere inprotocol/group, so it is a diagnostic, not a selection input. The right relationship is reuse of its components (the dialer-based HTTP client, the HTTP/3 transport, the accuracy/stability tracking idea) at a far smaller scale, plus the obvious complement: a user who seesurltestpick a bad outbound can runtools networkqualitytoday to confirm the diagnosis manually — which is evidence the problem is real, not evidence it is solved. - First-available / priority / fallback selection (#2130, #4065, #2061, #3830, PR #4217). Lets the user impose a static preference order manually. This works when the user knows in advance which outbound is better, but the motivating case is precisely that the ranking changes with network conditions during the day. Complementary, not a substitute.
- Load-balancing modes:
round_robin,least_connection,weighted_round_robin(#4110, #3023). Distribute load across outbounds by connection count or static weight. These never measure a path's actual capacity, so a shaped outbound keeps receiving its share. Note that #4110 and #4065 both propose amodefield onurltest; if either lands, this proposal'sstrategyshould be folded into that same field rather than adding a parallel one. - Passive throughput measurement from live traffic. Attractive because it costs nothing extra and reflects genuine usage. But it only produces data for the outbound currently selected — which is exactly the one suspected of being bad — leaving alternatives unmeasured. It could be a strong complement: passive observation to detect that the active outbound has degraded, triggering an active bounded probe of the alternatives. That layering may be a better long-term design than periodic probing alone.
- External speed test plus Clash API selector switching. Possible today, but requires an out-of-band process and defeats the purpose of an automatic group.
Design note: why latency-only ranking mis-ranks
The failure is not an implementation bug — it is that one scalar is being asked to stand in for two independent properties of a path.
| TTFB | Time for 256 KiB | Effective throughput | Ranked best today? | |
|---|---|---|---|---|
| A | 100 ms | 4.0 s | ~64 KiB/s (~0.5 Mbit/s) | Yes |
| B | 150 ms | 0.4 s | ~640 KiB/s (~5.2 Mbit/s) | No |
Under the current rule, A wins: its delay is lower, and B's 50 ms disadvantage does not clear tolerance. Yet for essentially every real workload — page loads, video, downloads — B is roughly ten times better. The 50 ms A saves on the first byte is repaid many times over on every byte after it.
A bounded probe that reads 256 KiB distinguishes these two cases directly, at a cost of a few hundred KiB per outbound per probe interval — while a HEAD request, by construction, cannot distinguish them at all.
Related issues
- #1494 — HTTP/2 ping health check. Complementary; optimizes the latency probe rather than adding a throughput dimension.
- #1528 — "true delay" via repeated latency sampling. Improves latency accuracy; still latency-only.
- #1385 — low-ping outbound that carries no data. Same underlying user pain (probe success does not imply usable path), approached via stall detection rather than measurement.
- #2130, #4065, #2061, #3830, and PR #4217 (
priorityoutbound group, withtier_up_checks/tier_down_checkshysteresis) — fallback / priority / smart selection. Static user-specified ordering; complementary to a measured signal. PR #4217 is also useful precedent for the hysteresis question raised above, and for adding a group-selection strategy without disturbingurltest's defaults. - #4110, #3023 — load-balancing modes for
urltest. Overlapping schema surface (mode); should be unified if either lands. - #4135 —
urltestvia HTTP client to support QUIC/HTTP3 targets. Directly relevant, and now largely unblocked: thecommon/httpclientpackage added on the 1.14 line provides HTTP/1.1, HTTP/2 and HTTP/3 transports, thoughurlteststill uses its own inlinehttp.Client. A bandwidth probe over a QUIC outbound wants the same rework. sing-box tools networkquality(new inv1.14.0-beta.8) — full Apple RPM capacity measurement over a chosen outbound. Prior art and a source of reusable components; see Alternatives considered for why it cannot itself be the periodic probe.- #3113 — protocol-dependent latency skew from session establishment (fixed for AnyTLS by PR #4376, which made it implement
OutboundWithMultiplexso it receives the warm-up probe). Retained here because it illustrates the general point that the probe measures setup cost as much as path quality. - #3976 — iOS Network Extension jetsam kill during speed testing. The binding memory constraint on this design.
- #4189 — HTTP 429 from a shared probe endpoint under the current latency probe. Motivates requiring a user-supplied bandwidth-probe URL.
I searched open and closed issues for urltest combined with bandwidth, throughput, speed, download, congestion, and performance terms, as well as bandwidth/throughput-aware routing, speed-test and probe outbounds, and every urltest-titled issue in the repository, and did not find an existing request for throughput-aware selection. If I missed one, I am glad to close this and move the discussion there.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with common/urltest/urltest.go and protocol/group/urltest.go to trace probe timing, history, and outbound selection. Then review adapter/experimental.go, option/group.go, docs/configuration/outbound/urltest.md, and common/networkquality to identify the configuration and compatibility decisions. Done means an agreed opt-in bounded bandwidth measurement integrates without changing the default HEAD-based behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- networking
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100