Skip to content

PCC Load Balancing: Per-Connection Classifier

PCC Load Balancing: Per-Connection Classifier

Section titled “PCC Load Balancing: Per-Connection Classifier”

Per-Connection Classifier (PCC) distributes outbound connections across multiple WAN links while keeping each individual connection on the same gateway for its entire lifetime. Unlike ECMP (which assigns individual packets round-robin), PCC assigns each connection to a WAN at the moment it is created and connection tracking remembers that assignment for all subsequent packets in that flow.

PCC hashes selected fields from the packet header — source address, destination address, source port, destination port, or any combination — and maps the result into a bucket. You divide the buckets among your WAN links; new connections landing in a given range of buckets are marked for the corresponding link.

Because connection tracking propagates the mark to all ESTABLISHED and RELATED packets, both directions of the flow follow the same path (symmetric routing). The hash stays constant throughout the life of the connection, so there are no mid-session gateway changes.

PCC matcher syntax:

per-connection-classifier=<hash-fields>:<total-buckets>/<bucket-id>
OptionDescription
both-addressesHash src IP + dst IP
both-portsHash src port + dst port
src-addressHash src IP only
dst-addressHash dst IP only
both-addresses-and-portsHash all four fields (recommended)
<total>/<id>Bucket count and zero-based bucket index
<total>/<id1>,<id2>,...Multiple buckets assigned to one mark (weighted)

Examples in this guide use the following layout:

LAN (bridge, 192.168.88.0/24)
MikroTik Router
├── ether1 → ISP1, gateway 203.0.113.1 (WAN1)
└── ether2 → ISP2, gateway 198.51.100.1 (WAN2)

RouterOS needs a dedicated routing table for each WAN so that marked packets can be forced through the correct gateway.

/routing table
add name=wan1 fib
add name=wan2 fib
/ip route
add dst-address=0.0.0.0/0 gateway=203.0.113.1 routing-table=wan1 check-gateway=ping
add dst-address=0.0.0.0/0 gateway=198.51.100.1 routing-table=wan2 check-gateway=ping

Also add a main-table default route pair for traffic that does not carry a routing mark (e.g. the router itself):

/ip route
add dst-address=0.0.0.0/0 gateway=203.0.113.1 distance=1 check-gateway=ping
add dst-address=0.0.0.0/0 gateway=198.51.100.1 distance=2 check-gateway=ping

The Mangle chain does two things for each connection:

  1. mark-connection — assigns a connection mark based on the PCC bucket (runs once, on the first NEW packet).
  2. mark-routing — maps the connection mark to a routing mark (runs on every packet including ESTABLISHED).
/ip firewall mangle
# --- WAN1: bucket 0 of 2 ---
add chain=prerouting action=mark-connection new-connection-mark=wan1_conn \
passthrough=yes \
per-connection-classifier=both-addresses-and-ports:2/0 \
connection-state=new \
in-interface=bridge comment="PCC LAN->WAN1"
add chain=prerouting action=mark-routing new-routing-mark=wan1 \
passthrough=no \
connection-mark=wan1_conn \
in-interface=bridge comment="Route WAN1 traffic"
# --- WAN2: bucket 1 of 2 ---
add chain=prerouting action=mark-connection new-connection-mark=wan2_conn \
passthrough=yes \
per-connection-classifier=both-addresses-and-ports:2/1 \
connection-state=new \
in-interface=bridge comment="PCC LAN->WAN2"
add chain=prerouting action=mark-routing new-routing-mark=wan2 \
passthrough=no \
connection-mark=wan2_conn \
in-interface=bridge comment="Route WAN2 traffic"

:::info New connections only The connection-state=new filter on the mark-connection rules ensures the PCC hash is evaluated only when a connection is created. Subsequent packets (ESTABLISHED/RELATED) inherit the mark from connection tracking and are handled by the mark-routing rules without re-hashing. :::

Each WAN interface needs its own masquerade rule so outbound packets carry the correct source address.

/ip firewall nat
add chain=srcnat action=masquerade out-interface=ether1 comment="Masq WAN1"
add chain=srcnat action=masquerade out-interface=ether2 comment="Masq WAN2"

Use multiple bucket IDs in a single rule to give one link a larger share of connections. The example below splits 10 total buckets: ISP1 gets 50%, ISP2 gets 30%, ISP3 gets 20%.

/routing table
add name=wan1 fib
add name=wan2 fib
add name=wan3 fib
/ip route
add dst-address=0.0.0.0/0 gateway=203.0.113.1 routing-table=wan1 check-gateway=ping
add dst-address=0.0.0.0/0 gateway=198.51.100.1 routing-table=wan2 check-gateway=ping
add dst-address=0.0.0.0/0 gateway=192.0.2.1 routing-table=wan3 check-gateway=ping
/ip firewall mangle
# ISP1 — buckets 0-4 (50%)
add chain=prerouting action=mark-connection new-connection-mark=wan1_conn \
passthrough=yes connection-state=new \
per-connection-classifier=both-addresses-and-ports:10/0,1,2,3,4
add chain=prerouting action=mark-routing new-routing-mark=wan1 \
passthrough=no connection-mark=wan1_conn in-interface=bridge
# ISP2 — buckets 5-7 (30%)
add chain=prerouting action=mark-connection new-connection-mark=wan2_conn \
passthrough=yes connection-state=new \
per-connection-classifier=both-addresses-and-ports:10/5,6,7
add chain=prerouting action=mark-routing new-routing-mark=wan2 \
passthrough=no connection-mark=wan2_conn in-interface=bridge
# ISP3 — buckets 8-9 (20%)
add chain=prerouting action=mark-connection new-connection-mark=wan3_conn \
passthrough=yes connection-state=new \
per-connection-classifier=both-addresses-and-ports:10/8,9
add chain=prerouting action=mark-routing new-routing-mark=wan3 \
passthrough=no connection-mark=wan3_conn in-interface=bridge
/ip firewall nat
add chain=srcnat action=masquerade out-interface=ether1
add chain=srcnat action=masquerade out-interface=ether2
add chain=srcnat action=masquerade out-interface=ether3

Symmetric routing means both directions of a connection use the same WAN link:

  • Outbound (LAN → Internet): the mark-routing rule in prerouting directs the packet to the correct routing table.
  • Inbound / reply (Internet → LAN): reply packets arrive on the WAN interface they were sent through. Connection tracking recognises the ESTABLISHED flow and the mark-routing rule re-applies the same routing mark, which causes RouterOS to use the routing table for that WAN.

This is automatic as long as:

  1. The mark-connection rules fire only on NEW packets (preventing re-hashing of established flows).
  2. The mark-routing rules match on connection-mark (not per-connection-classifier) so they apply to ESTABLISHED packets too.
  3. Each WAN interface has a masquerade NAT rule, ensuring the source address is appropriate for the ISP.

PCC connections fail when a WAN link drops because the marked routing table no longer has a reachable route. The following techniques reduce the impact:

Add check-gateway=ping to each routing-table default route. RouterOS periodically pings the gateway; when it becomes unreachable the route is removed from the table.

/ip route
add dst-address=0.0.0.0/0 gateway=203.0.113.1 routing-table=wan1 check-gateway=ping
add dst-address=0.0.0.0/0 gateway=198.51.100.1 routing-table=wan2 check-gateway=ping

When a routing-table route disappears, packets with that routing mark have nowhere to go. Existing connections on the failed link will drop. New connections will not start (the PCC hash still points to the dead table). To redirect new connections, flush the connection marks for the failed WAN using a Netwatch script or manual /ip firewall connection remove:

# Example: run in a Netwatch down-script for ISP1
/ip firewall connection remove [find connection-mark=wan1_conn]

This forces those sessions to start fresh on the next NEW packet and pick up a route from the remaining active WAN via the main-table fallback.

Always keep a pair of distance-prioritised routes in the main routing table. These serve traffic from the router itself and act as a catch-all when PCC-marked routing tables lose their routes.

/ip route
add dst-address=0.0.0.0/0 gateway=203.0.113.1 distance=1 check-gateway=ping
add dst-address=0.0.0.0/0 gateway=198.51.100.1 distance=2 check-gateway=ping

Exclude Local and Management Traffic from PCC

Section titled “Exclude Local and Management Traffic from PCC”

Prevent management sessions from being load-balanced, which can cause unexpected loss of access when a WAN drops:

/ip firewall mangle
add chain=prerouting action=accept \
dst-address=<router-management-ip> \
comment="Skip PCC for management" place-before=0

# Active connections and their marks
/ip firewall connection print where connection-mark~"wan"
# Routing tables
/ip route print where routing-table=wan1
/ip route print where routing-table=wan2
# Mangle counters (confirm rules are matching)
/ip firewall mangle print stats

Expected output for a working 2-WAN setup: both wan1_conn and wan2_conn connection marks are present, and the per-table routes are active (not flagged as unreachable).


LimitationDetail
Single-host penaltyA single client talking to many servers gets good distribution. A single client with a single destination may always hash to the same WAN. Use src-address-and-dst-port or both-addresses-and-ports to improve spread.
Existing session drops on failoverWhen a WAN drops, all connections on that link drop. PCC does not migrate sessions.
Server-side inbound trafficPCC affects outbound routing only. Inbound traffic (hosted services) depends on DNS / BGP, not PCC.
IPv6PCC rules must be duplicated in /ipv6 firewall mangle for IPv6 traffic.