Skip to content

Feature request: Optional bandwidth-aware mode for URLTest #4397

Description

@zydo

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.

Probecommon/urltest/urltest.go:

Stored metricadapter/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.

Selectionprotocol/group/urltest.go#L287-L329:

Schedulinginterval 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.

Schemaoption/group.go#L11-L18outbounds, 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:

  1. Issue a bounded GET instead of HEAD, against a configurable URL that returns a payload of known minimum size.
  2. Record TTFB — time to response headers — using the existing timer semantics, so the current metric is preserved unchanged and remains available.
  3. 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.
  4. Compute effective throughput as bytes_read / transfer_time, where transfer_time excludes TTFB, so the value reflects the data phase rather than connection setup.
  5. 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.
  6. Store both metrics, extending URLTestHistory with optional fields (e.g. Throughput uint32 in bytes/sec and Bytes uint32 actually read, zero when the mode is off), keeping the existing Delay field'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_test is a nested object so that the whole feature is one enabled: false away from being inert, and so no existing field changes meaning.
  • A separate url is required — generate_204 returns no body and cannot serve as a throughput target.
  • A separate, longer interval matters: 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 latency interval, not to the same value.
  • A separate, lower concurrency than 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, with throughput_tolerance as 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 exceeds latency_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

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions