SpeedLimit
SpeedLimit is a pass-through tunnel that rate-limits payload bytes with a token bucket. It does not rewrite protocol
data, add headers, remove headers, or change the destination context. It only decides whether a payload can pass now,
must wait, or should be dropped.
The same bucket accounts for both directions that use it. For example, if one bucket is configured for 1 MB/s, upload
and download through that bucket share the same 1 MB/s.
Typical placement
Stream shaping:
TcpListener -> SpeedLimit -> TcpConnector
TcpListener -> TlsServer -> SpeedLimit -> TcpConnector
Packet or datagram limiting:
UdpListener -> SpeedLimit -> UdpConnector
TunDevice -> SpeedLimit -> UdpStatelessSocket
Use pause mode for stream-like traffic where excess data should wait. Use drop mode for packet or datagram traffic
where old packets are usually worse than missing packets.
Stream example
Each connection gets its own 256 KiB/s bucket, and excess stream data is queued until enough tokens refill.
{
"name": "speed-limit",
"type": "SpeedLimit",
"settings": {
"kilo-bytes-per-sec": 256,
"limit-mode": "per-line",
"work-mode": "pause"
},
"next": "next-node"
}
Shared cap example
All lines passing through this SpeedLimit instance share one 8 MiB/s bucket.
{
"name": "global-speed-limit",
"type": "SpeedLimit",
"settings": {
"mega-bytes-per-sec": 8,
"limit-mode": "all-lines",
"token-recharge-rate": 20,
"work-mode": "pause"
},
"next": "next-node"
}
Packet drop example
Each worker gets a 500000 bytes/s shared bucket. Payload chunks that do not fit are discarded.
{
"name": "packet-speed-limit",
"type": "SpeedLimit",
"settings": {
"bytes-per-sec": 500000,
"limit-mode": "per-worker",
"work-mode": "drop"
},
"next": "next-node"
}
Required fields
settings is required and must be a non-empty object.
Exactly one rate field must be configured:
| Field | Meaning |
|---|---|
bytes-per-sec | Bytes per second. |
kilo-bytes-per-sec | Kibibytes per second, multiplied by 1024. |
mega-bytes-per-sec | Mebibytes per second, multiplied by 1024 * 1024. |
The selected value must be a number greater than 0. If more than one rate field is present, node creation fails.
You must also configure:
| Field | Accepted values |
|---|---|
limit-mode | per-line, per-connection, all-lines, all-connections, per-worker |
work-mode | pause, drop |
These string values are case-sensitive in the current parser.
Optional fields
| Field | Default | Description |
|---|---|---|
token-recharge-rate | 10 | Token refill interval in milliseconds. Must be greater than 0. |
token-recharge-rate is not a bandwidth setting. It controls how often tokens are added to the bucket.
For example:
bytes-per-sec: 1000token-recharge-rate: 10
The bucket receives about 10 bytes worth of tokens every 10 ms. A smaller interval usually feels smoother. A larger interval can be more bursty because traffic is released in larger time steps.
Limit modes
| Mode | Behavior |
|---|---|
per-line / per-connection | Every Waterwall line gets its own bucket. Two simultaneous lines configured for 1 MB/s can together use about 2 MB/s. |
all-lines / all-connections | All lines through this tunnel instance share one atomic global bucket across workers. A 1 MB/s setting means the whole node shares 1 MB/s. |
per-worker | Each worker has its own shared bucket. With 4 workers and 1 MB/s, the practical total can reach about 4 MB/s. |
Each bucket starts full and has a capacity equal to one second of the configured rate. This allows an initial burst of up to roughly one second of traffic before the limiter settles into the configured average rate.
Work modes
pause
pause mode is intended for ordered stream traffic.
When a payload does not fit in the bucket:
- The buffer is queued inside
SpeedLimit. - The source side is paused with Waterwall
Pausecallbacks. - A worker timer waits until enough tokens refill.
- Queued data is drained in order.
- When the queue becomes empty and external pressure has cleared, the source side is resumed.
If a queued buffer is larger than the currently available token grant, SpeedLimit may send a leading slice of that
buffer and keep the remainder queued for a later timer tick. This preserves byte order while shaping the average rate.
pause mode can hold buffers in memory. Very low limits, long-lived stalled peers, or high incoming rates can therefore
increase memory usage and latency.
drop
drop mode is intended for packet or datagram traffic.
For each payload chunk:
- if the bucket has enough tokens for the whole chunk, the chunk is forwarded
- if the bucket does not have enough tokens, the whole chunk is discarded
drop mode does not split payload chunks and does not queue them for later. This is usually the better behavior for UDP
or packet chains because queued old datagrams often become useless.
Direction and backpressure behavior
Payload limiting and backpressure are symmetrical:
- upstream payload uses the same selected bucket logic as downstream payload
- upstream queued data locally pauses the previous side
- downstream queued data locally pauses the next side
- external
PauseandResumecallbacks are remembered soSpeedLimitdoes not accidentally resume a side that is still externally paused
This means pause mode composes with normal Waterwall stream backpressure instead of just sleeping inside the payload
callback.
Splice support and bounded retention
Each direction has a separate FIFO bounded by 16 MiB of logical payload, 32 MiB of retained buffer capacity charge, and 1,024 buffers. Empty buffers still count toward the charge and entry limits. Admission refusal closes the affected TCP connection and releases both queues; accepted TCP bytes are never silently dropped.
A consumer Pause stops the corresponding timer drain. The queued data stays owned by this node, without repeated timer polling while paused. Resume rearms the drain, but the source remains paused until both the local queue hold and external pressure have cleared. If creating or rearming the drain timer is refused, both queues are released and the affected connection closes through its owner. Notification callbacks can close the line safely.
The node advertises kNodeFlagSupportsSplice. Whole-buffer forwarding and queued
payloads preserve private pipes. In pause mode, partial grants use sbufMoveRangeTo()
to move exactly the granted bytes into an independent pipe, with ordinary fallback
if pipe allocation or transfer cannot complete. The older remainder stays ahead of
nested input. Token accounting includes resident prefixes and pipe bodies. drop
mode makes its whole-buffer decision without reading payload bytes.
Splice I/O still requires support throughout the finalized stream chain. Packet chains and unsupported platforms use ordinary buffers.
Packet-line caveat
Waterwall packet lines are worker-local helper lines that are reused for many packets. In packet chains, per-line does
not mean "per real network flow"; it means the bucket attached to that worker's persistent packet line. If you need
packet limiting that scales by worker, use per-worker explicitly. If you need one cap across all workers, use
all-lines.
For packet or UDP-style chains, prefer work-mode: "drop" unless you specifically want backpressure and delayed packet
delivery.
Finish behavior
Line state is initialized only by upstream Init. Downstream Init is forbidden and terminates the process through
abortProgramNow(1). Downstream payload remains supported after upstream initialization.
Do not place this node between Bridge and ReverseClient; those nodes must be directly adjacent when connected in
that topology.
On Finish, SpeedLimit destroys its local line state, cancels any drain
timers, releases queued buffers, and then forwards the Waterwall Finish in the same direction.
The tunnel does not prepend data, so required_padding_left is 0.
Common mistakes
- Do not configure more than one rate field.
- Do not use
pausemode for packet chains unless delayed packets are acceptable. - Do not expect
per-workerto enforce one global cap; it scales with worker count. - Do not expect
per-linein packet chains to mean per-client or per-flow. - Do not set an extremely large
token-recharge-rateunless bursty delivery is acceptable.
Node Metadata
Source-backed metadata:
| Property | Value |
|---|---|
| node flags | kNodeFlagSupportsSplice |
can_have_prev | true |
can_have_next | true |
layer_group | kNodeLayerAnything |
layer_group_prev_node | kNodeLayerSameAsNext |
layer_group_next_node | kNodeLayerSameAsPrev |
required_padding_left | 0 bytes |