
Shape Linux Egress Traffic With HTB and Tc
Use HTB when you need predictable outbound bandwidth guarantees on Linux, and trust the tc counters over any command you pasted. The Hierarchical Token Bucket qdisc (queueing discipline) is the right tool for this job. It only works when the class tree, burst sizes, default class, and filters all match the packets that actually leave the interface. Get one of those wrong and tc accepts the config without complaint while your traffic goes out unshaped.
The setup runs in order. Add an HTB root qdisc with tc, the traffic control tool from iproute2. Build a class tree under it, attach filters that sort packets into leaf classes, and read the counters to prove traffic lands where you meant.
HTB shapes what leaves the box
HTB controls one thing: when packets leave a network interface, and in what order. It runs in the egress path. Routing has already picked an output device and netfilter has had its say, but the driver hasn't yet handed the packet to the network card. Traffic arriving on that interface never passes through it.
That scope matters because shaping and policing are different tools. Shaping holds excess packets in a queue and releases them at the configured rate, so you pay in latency and memory. Policing drops or remarks excess packets the moment they show up, so you pay in retransmits. HTB is a shaper.
The workflow has two halves. First you define classes with their rate limits. Then you add rules that sort traffic into those classes. Most HTB failures come from one half disagreeing with the other.
There's a practical catch. You can only shape at a bottleneck you own. If your modem has a deeper buffer than your box, the queue forms there and HTB never sees congestion. So set your top rate a little below what the uplink really delivers, and the queue builds where you can manage it.
How does Linux traffic shaping with tc and an HTB qdisc work?
HTB is a hierarchy of Token Bucket Filters attached to an interface. You build it from a root qdisc, classes, and the filters that feed them. The root qdisc owns the interface and gets a handle such as 1:. Classes hang below it with IDs like 1:10, and each carries a guaranteed rate and a ceil, the most a class can send if its parent has bandwidth to spare.
Only leaf classes hold packets. Inner classes do the accounting and lend tokens to their children. Here is what happens when a leaf has traffic queued:
- Below its
rate, it sends on its own tokens. - Between
rateandceil, it borrows unused tokens from its parent. - At
ceil, it waits, however idle the link is.
When several children want to borrow, prio decides who gets the spare bandwidth first. The lower number wins. Whatever capacity is left is then split in proportion to each class's quantum.
The structural gotcha is the single top class. Classes attached straight to the root qdisc cannot lend to each other, because the qdisc holds no tokens. So put a single parent class under the root, sized to the link, and hang everything else under it:
1: htb root qdisc, default 30
└── 1:1 link class, rate = ceil = shaped uplink
├── 1:10 web (50% guaranteed)
├── 1:20 mail (30% guaranteed)
└── 1:30 other (20% guaranteed, default class)
That layout is the classic HTB sharing example. Web traffic gets 50% of the bandwidth, mail gets 30%, and the remaining 20% is shared by everything else.
Token buckets are why rate limits burst
Each HTB class keeps a token bucket for its rate and another for its ceil. The rate bucket fills at rate and holds up to burst bytes. The ceil bucket fills at ceil and holds up to cburst bytes. A packet leaves only when the buckets it needs hold enough tokens for its size.
As a result, a class that sat idle can send a full bucket at line speed before the rate limit bites. That spike is intended. Bursts let short transfers finish fast. They also let HTB hit its rate even though the kernel refills buckets on timer events, not continuously.
Sizing goes wrong in both directions:
- Burst too small: the bucket drains between timer wakeups, so the class tops out below its rate. Overlimits climb, the measured rate falls short, and the CPU looks idle.
- Burst too large: every idle period ends with a line-rate blast that overruns the downstream buffer you were protecting. Latency spikes come back in waves.
If you leave burst and cburst out, tc computes defaults from the rate and the kernel's timer resolution. On a modern kernel with high-resolution timers, those defaults are usually fine. I set burst by hand only when the counters prove the default wrong. The tiny fixed values in old HOWTOs were written for slower timers, so ignore them.
Segmentation offload adds another distortion. With TSO or GSO (TCP and generic segmentation offload) on, the stack hands HTB large super-packets that get split later. At low rates each one is a big lump, and latency turns jumpy.
On a slow uplink, test ethtool -K eth0 tso off gso off. Run ethtool -k eth0 first so you know what you changed.
Build a minimal HTB root and class tree

The class syntax from the tc-htb man page is the reference worth keeping open:
tc class ... dev dev parent major:[minor] [ classid major:minor ] htb rate rate [ ceil rate ] burst bytes [ cburst bytes ] [ prio priority ] [ quantum bytes ]
Check your units before you type a single rate. In tc, bit (or a bare number), kbit, and mbit are bits per second. Meanwhile bps, kbps, and mbps are bytes per second. Type mbps while thinking of your ISP's megabits, and every class now runs in megabytes per second. That's far faster than you meant, and tc accepts it without a word.
The script below assumes you shape eth0 to 100mbit. Swap in your own numbers.
#!/bin/sh
set -e
DEV=eth0
tc qdisc del dev "$DEV" root 2>/dev/null || true
tc qdisc add dev "$DEV" root handle 1: htb default 30
tc class add dev "$DEV" parent 1: classid 1:1 htb rate 100mbit ceil 100mbit
tc class add dev "$DEV" parent 1:1 classid 1:10 htb rate 50mbit ceil 100mbit prio 1
tc class add dev "$DEV" parent 1:1 classid 1:20 htb rate 30mbit ceil 100mbit prio 2
tc class add dev "$DEV" parent 1:1 classid 1:30 htb rate 20mbit ceil 100mbit prio 3
The set -e line earns its place. A tc command that fails halfway leaves a partial tree, and the next lines happily build on top of it. Without it, the script's exit code still reports success while you shape with half a config.
Next, run tc class show dev eth0. You should see 1:1 marked root, and 1:10, 1:20, and 1:30 each listing parent 1:1. If a class is missing, its add failed, and that error went to your terminal and nowhere else.
Rate is the guarantee and ceil is the borrowing limit
Add up the child rates first. Their total must fit inside the parent's rate, or the guarantees are fiction. HTB won't refuse an oversubscribed tree. It accepts the config and fails to deliver the moment every class gets busy at once.
Ceil is where you decide how polite each class has to be. Setting every ceil to the parent's rate lets any class use the whole link while the others sit idle. Lower a ceil when a class should never own the whole pipe, such as an offsite backup that would otherwise flatten someone's video call.
Meanwhile, use prio for latency-sensitive classes that should borrow first. It only changes borrowing order, so a low prio number never lifts a class past its ceil.
Quantum controls how many bytes each class sends per round when siblings share spare bandwidth. HTB derives it from the class rate divided by the root's r2q value. Very low or very high rates push it out of range, and the kernel says so in the kernel ring buffer. After building the tree, run this:
dmesg -T | grep -i htb
A line saying the quantum of class ... is big and suggesting an r2q change means borrowing will be lumpy. The class ID in that line is printed in hex with the colon dropped, so translate it back to the classid you wrote. Fix it by setting quantum on that class, or by passing r2q to the root qdisc, so every class lands at an MTU or more.
Give unclassified packets a deliberate default class
The root line tc qdisc add dev eth0 root handle 1: htb default 30 sends any packet no filter claims into class 1:30. That default argument matters more than anything else on the line. It is also optional, which is exactly the problem.
Leave it out, or point it at a class that doesn't exist, and unmatched packets skip shaping entirely. HTB sends them straight to the device at full speed, and nothing errors. The only evidence is a counter on the root qdisc:
tc -s qdisc show dev eth0 | grep -o 'direct_packets_stat [0-9]*'
If that number climbs while traffic flows, something is bypassing your policy. So test the default path on purpose. Send traffic none of your filters match, such as a ping to the gateway, and watch class 1:30's packet count rise in tc -s class show dev eth0.
Size the default class with care. ARP, IPv6 neighbor discovery, DNS, and anything you forgot to classify all land there. Starve it, and the box starts failing in ways that look unrelated to bandwidth.
Which filter should classify your packets?

Filters attach to the HTB qdisc (parent 1:) and send a matched packet to a leaf classid. tc checks them in prio order, lowest first, and the first match wins. Most setups need one of these classifiers:
- u32 matches header fields such as ports and addresses directly in tc. It's fine for a short list of port rules.
- fw matches a firewall mark set by nftables or iptables. Use it once classification needs connection tracking or anything u32 makes ugly.
Append these to the build script for the web and mail classes:
tc filter add dev "$DEV" parent 1: protocol ip prio 1 u32 match ip dport 80 0xffff flowid 1:10
tc filter add dev "$DEV" parent 1: protocol ip prio 1 u32 match ip dport 443 0xffff flowid 1:10
tc filter add dev "$DEV" parent 1: protocol ip prio 2 u32 match ip dport 25 0xffff flowid 1:20
Check the direction before you trust that. dport fits a client sending to remote servers. However, if this box is the web or mail server, its outbound replies carry the service port as the source, so match sport instead. Get it backwards and every packet falls through to the default class.
IPv6 is the other quiet leak. protocol ip only matches IPv4, so IPv6 traffic skips these rules entirely. Add matching filters with protocol ipv6 and match ip6 dport. Give them their own prio, because tc rejects a second protocol at a priority that's already in use.
What I do once the rule list grows past a few ports is move classification into the firewall. Mark packets in nftables with meta mark set, then let tc read the mark with tc filter add dev eth0 parent 1: protocol all prio 10 handle 2 fw flowid 1:20. The firewall rules stay readable, and conntrack does the hard part.
Read the class counters to prove it works

A config that loads proves nothing about where packets go. So put load through each class on purpose and watch the numbers move:
watch -n 1 'tc -s class show dev eth0'
Here is what each counter tells you:
- Sent bytes and packets should climb on the class your filter targets. If they climb on 1:30 instead, the filter missed.
- borrowed rising on a leaf means it's running above its rate on spare bandwidth from 1:1. That's the sharing working.
- overlimits counts the times a class hit a limit and had to wait. On a saturated class, that's the shaper doing its job.
- dropped means the leaf's queue overflowed. Drops under sustained overload are normal, while drops on a lightly used class point to a sizing bug.
- backlog is what's queued right now. A class that always shows a backlog is adding latency.
For throughput, run iperf3 -s -p 443 on a test host past your uplink, then iperf3 -c <host> -p 443 from the shaped box. That upload matches the web filter, so it should land in 1:10. With the other classes idle, the measured rate should settle at or below that class's ceil. If it runs past, recheck your units and the bypass counter before you blame HTB.
FAQ
Can HTB shape traffic coming into the box?
Not on the inbound interface itself. The usual workaround redirects ingress traffic to an IFB (Intermediate Functional Block) device with the mirred action, then hangs an HTB tree off that device's egress. It works, with a catch: by the time packets reach you, the upstream link already carried them. You're relying on TCP to slow down after you delay or drop its packets, so inbound control is always looser than outbound.
Should leaf classes keep their default queue?
I replace it. Each HTB leaf gets a plain pfifo by default, so a single bulk flow can fill the queue and drag every other flow in that class behind it. Attach a fair queue per leaf instead:
tc qdisc add dev eth0 parent 1:10 handle 10: fq_codel
Repeat for each leaf with its own handle. fq_codel spreads the class's bandwidth across flows and keeps queues short.
Does the tc config survive a reboot?
No. The tree lives in kernel memory and disappears at boot. It also vanishes whenever the interface is deleted and recreated, which happens with PPP links and some USB adapters. Wrap the script in a oneshot systemd unit with RemainAfterExit=yes, plus BindsTo= and After= on sys-subsystem-net-devices-eth0.device.
For interfaces that come and go, trigger the script from a NetworkManager or networkd-dispatcher hook instead. After a reboot, run tc qdisc show dev eth0 and confirm htb is actually there.
Is CAKE a simpler choice than HTB?
Often, yes. If you have a single uplink and want fair sharing with low latency, tc qdisc add dev eth0 root cake bandwidth <rate> does that without hand-built classes. HTB earns its complexity when you need hard per-class guarantees, like the web, mail, and catch-all split above. Pick CAKE for a home gateway that just needs to stay responsive under load. Pick HTB when someone hands you a bandwidth policy to enforce.
