Computer Networks Module 5 – Switching, spanning tree and VLANs
How switches learn, flood and forward
How an Ethernet switch builds its MAC address table, when it forwards, floods or filters a frame, why entries age out, and how hubs and routers differ.
What you will learn
- Explain how a learning switch builds its MAC address table and forwards, floods or filters frames
- Distinguish hubs, switches and routers by collision and broadcast domains
- Trace a frame through a multi-switch network given each switch's MAC table
- Choose an ageing time by weighing flooding against stale entries
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a learning switch builds its MAC address table and forwards, floods or filters frames
- Distinguish hubs, switches and routers by collision and broadcast domains
- Trace a frame through a multi-switch network given each switch's MAC table
- Choose an ageing time by weighing flooding against stale entries
Before you start
On this page
An Ethernet switch decides where each frame goes by its destination MAC address, and it learns where addresses are by reading valid source addresses on learning-enabled ports. For an admitted frame, the switch first records the source address against that port in its MAC address table (IEEE 802.1Q calls it the filtering database). Then it looks up the destination. If the table places the destination on another port, the switch forwards the frame out of that port alone; if the destination sits on the very port the frame came in on, it filters the frame, which means it drops it; and if the destination is unknown, a broadcast, or a multicast group it has no information about under the stated flood policy, it floods to eligible ports in that VLAN, excluding ingress. Dynamic entries expire after an ageing time, 300 seconds by default on a Linux bridge and on Cisco’s Catalyst 9300 switches, so the table keeps up with hosts that move or fall silent.
A source address teaches the ingress port
The valid frames in this model follow the same three steps, in this order:
- Source address learning: The update that records the port and time where a valid source address was most recently observed in a bridge forwarding context. Record “the frame’s source address is reachable through the port it arrived on, as of now”. A new address gets an entry, a known address has its timer reset, and an address that shows up on a different port is moved to that port.
- Look up the destination address. Only an entry that has not aged out counts.
- Decide. Forward to the port in the entry, filter if that port is the arrival port, and flood if there is no usable entry or the destination is a group address.
The program below is that algorithm as a short class, fed with eight frames on a five-port switch. The hosts use MAC addresses from the block reserved for documentation, 00:00:5e:00:53:00 to 00:00:5e:00:53:ff (RFC 9542), and each gets a letter so the output stays readable.
"""A learning switch: learn the source address, then forward, flood or filter by the destination."""
BROADCAST = "ff:ff:ff:ff:ff:ff"
AGEING = 300 # seconds an entry lives after its address was last seen as a source
# Documentation addresses (00:00:5e:00:53:xx), one letter per host
NAMES = {f"00:00:5e:00:53:0{c.lower()}": c for c in "ABCDE"} | {BROADCAST: "all"}
MAC = {name: mac for mac, name in NAMES.items()}
class LearningSwitch:
def __init__(self, ports, ageing=AGEING):
self.ports = ports
self.ageing = ageing
self.table = {} # MAC address -> (port, time it was last seen as a source)
def lookup(self, mac, now):
"""The port of a MAC address, or None if it is unknown or its entry has aged out."""
entry = self.table.get(mac)
if entry is not None and now - entry[1] < self.ageing:
return entry[0]
return None
def receive(self, port, src, dst, now):
"""Learn from the frame's source, then decide what to do with it."""
before = self.lookup(src, now)
self.table[src] = (port, now)
if before is None:
learned = f"learn {NAMES[src]} on {port}"
elif before != port:
learned = f"move {NAMES[src]} {before}->{port}"
else:
learned = f"refresh {NAMES[src]}"
out = None if dst == BROADCAST else self.lookup(dst, now)
if out == port:
return learned, "filter: already on that side", []
if out is not None:
return learned, f"forward to {out}", [out]
flood = [p for p in self.ports if p != port]
reason = "broadcast" if dst == BROADCAST else "unknown"
return learned, f"{reason}: flood to {','.join(map(str, flood))}", flood
switch = LearningSwitch(ports=[1, 2, 3, 4, 5])
events = [ # (time in seconds, ingress port, source, destination)
(0, 1, "A", "B"),
(1, 2, "B", "A"),
(2, 1, "A", "B"),
(3, 3, "C", "all"),
(4, 4, "D", "C"),
(5, 4, "E", "D"), # D and E sit behind a small switch on port 4
(20, 5, "B", "A"), # B's laptop is now plugged into port 5
(400, 3, "C", "A"), # A has said nothing since t=2
]
print(f"{'t (s)':>5} {'port':>4} {'frame':<8} {'learning':<14} decision")
for now, port, src, dst in events:
learned, decision, _ = switch.receive(port, MAC[src], MAC[dst], now)
print(f"{now:>5} {port:>4} {src + ' -> ' + dst:<8} {learned:<14} {decision}")
print("\nMAC table at t=400 s:")
for mac, (port, seen) in sorted(switch.table.items(), key=lambda item: NAMES[item[0]]):
state = "aged out" if 400 - seen >= AGEING else f"age {400 - seen} s"
print(f" {mac} ({NAMES[mac]}) port {port} {state}") Output
t (s) port frame learning decision
0 1 A -> B learn A on 1 unknown: flood to 2,3,4,5
1 2 B -> A learn B on 2 forward to 1
2 1 A -> B refresh A forward to 2
3 3 C -> all learn C on 3 broadcast: flood to 1,2,4,5
4 4 D -> C learn D on 4 forward to 3
5 4 E -> D learn E on 4 filter: already on that side
20 5 B -> A move B 2->5 forward to 1
400 3 C -> A learn C on 3 unknown: flood to 1,2,4,5
MAC table at t=400 s:
00:00:5e:00:53:0a (A) port 1 aged out
00:00:5e:00:53:0b (B) port 5 aged out
00:00:5e:00:53:0c (C) port 3 age 0 s
00:00:5e:00:53:0d (D) port 4 aged out
00:00:5e:00:53:0e (E) port 4 aged out
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 learning_switch.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Python 3.14 · main.py
table: dict[str, tuple[int, int]] = {}
def lookup(address: str, now: int) -> int | None:
entry = table.get(address)
return entry[0] if entry is not None and now - entry[1] < 300 else None
def receive(
now: int, port: int, source: str, destination: str
) -> tuple[str, list[int]]:
table[source] = (port, now)
out = None if destination == "all" else lookup(destination, now)
if out == port:
return "filter", []
if out is not None:
return "forward", [out]
return "broadcast" if destination == "all" else "unknown", [
p for p in range(1, 6) if p != port
]
events = [
(0, 1, "A", "B"),
(1, 2, "B", "A"),
(2, 1, "A", "B"),
(3, 3, "C", "all"),
(4, 4, "D", "C"),
(5, 4, "E", "D"),
(20, 5, "B", "A"),
(400, 3, "C", "A"),
]
for now, port, source, destination in events:
decision, ports = receive(now, port, source, destination)
shown = ",".join(map(str, ports)) or "none"
print(f"t={now} {source}->{destination} {decision} ports={shown}")
for source, (port, seen) in sorted(table.items()):
usable = str(lookup(source, 400) is not None).lower()
print(f"{source} port={port} age={400 - seen} usable={usable}") Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const table = new Map();
function lookup(address, now) {
const entry = table.get(address);
return entry !== undefined && now - entry[1] < 300 ? entry[0] : null;
}
const events = [
[0, 1, "A", "B"],
[1, 2, "B", "A"],
[2, 1, "A", "B"],
[3, 3, "C", "all"],
[4, 4, "D", "C"],
[5, 4, "E", "D"],
[20, 5, "B", "A"],
[400, 3, "C", "A"],
];
for (const [now, port, source, destination] of events) {
table.set(source, [port, now]);
const out = destination === "all" ? null : lookup(destination, now);
const decision =
out === port
? "filter"
: out !== null
? "forward"
: destination === "all"
? "broadcast"
: "unknown";
const ports =
out === port ? [] : out !== null ? [out] : [1, 2, 3, 4, 5].filter((p) => p !== port);
console.log(`t=${now} ${source}->${destination} ${decision} ports=${ports.join(",") || "none"}`);
}
for (const source of [...table.keys()].sort()) {
const [port, seen] = table.get(source);
console.log(`${source} port=${port} age=${400 - seen} usable=${lookup(source, 400) !== null}`);
} Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.HashMap;
import java.util.TreeSet;
public class Main {
record Entry(int port, int seen) {}
record Event(int now, int port, String source, String destination) {}
static final HashMap<String, Entry> table = new HashMap<>();
static Integer lookup(String source, int now) {
Entry e = table.get(source);
return e != null && now - e.seen() < 300 ? e.port() : null;
}
public static void main(String[] args) {
Event[] events = {
new Event(0, 1, "A", "B"),
new Event(1, 2, "B", "A"),
new Event(2, 1, "A", "B"),
new Event(3, 3, "C", "all"),
new Event(4, 4, "D", "C"),
new Event(5, 4, "E", "D"),
new Event(20, 5, "B", "A"),
new Event(400, 3, "C", "A")
};
for (Event e : events) {
table.put(e.source(), new Entry(e.port(), e.now()));
Integer out = e.destination().equals("all") ? null : lookup(e.destination(), e.now());
String decision =
out != null && out == e.port()
? "filter"
: out != null ? "forward" : e.destination().equals("all") ? "broadcast" : "unknown";
var ports = new StringBuilder();
for (int p = 1; p <= 5; p++) {
if (out != null ? p == out && out != e.port() : p != e.port()) {
if (ports.length() > 0) ports.append(',');
ports.append(p);
}
}
String shown = ports.length() == 0 ? "none" : ports.toString();
System.out.println(
"t="
+ e.now()
+ " "
+ e.source()
+ "->"
+ e.destination()
+ " "
+ decision
+ " ports="
+ shown);
}
for (String source : new TreeSet<>(table.keySet())) {
Entry e = table.get(source);
System.out.println(
source
+ " port="
+ e.port()
+ " age="
+ (400 - e.seen())
+ " usable="
+ (lookup(source, 400) != null));
}
}
} Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <map>
#include <optional>
#include <print>
#include <string>
#include <tuple>
#include <vector>
using Table = std::map<std::string, std::pair<int, int>>;
std::optional<int> lookup(const Table &table, const std::string &source,
int now) {
const auto e = table.find(source);
if (e == table.end() || now - e->second.second >= 300)
return std::nullopt;
return e->second.first;
}
int main() {
Table table;
const std::vector<std::tuple<int, int, std::string, std::string>> events = {
{0, 1, "A", "B"}, {1, 2, "B", "A"}, {2, 1, "A", "B"},
{3, 3, "C", "all"}, {4, 4, "D", "C"}, {5, 4, "E", "D"},
{20, 5, "B", "A"}, {400, 3, "C", "A"}};
for (const auto &[now, port, source, destination] : events) {
table[source] = {port, now};
const auto out =
destination == "all" ? std::nullopt : lookup(table, destination, now);
const auto decision = out && *out == port ? "filter"
: out ? "forward"
: destination == "all" ? "broadcast"
: "unknown";
std::string ports;
for (int p = 1; p <= 5; p++)
if (out ? p == *out && *out != port : p != port) {
if (!ports.empty())
ports += ",";
ports += std::to_string(p);
}
std::println("t={} {}->{} {} ports={}", now, source, destination, decision,
ports.empty() ? "none" : ports);
}
for (const auto &[source, e] : table) {
std::println("{} port={} age={} usable={}", source, e.first, 400 - e.second,
lookup(table, source, 400).has_value());
}
} Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Dictionary<string, (int Port, int Seen)> table = new();
int? Lookup(string source, int now) =>
table.TryGetValue(source, out var entry) && now - entry.Seen < 300 ? entry.Port : null;
(int Now, int Port, string Source, string Destination)[] events = [(0,1,"A","B"),
(1,2,"B","A"),(2,1,"A","B"),(3,3,"C","all"),(4,4,"D","C"),(5,4,"E","D"),
(20,5,"B","A"),(400,3,"C","A")];
foreach (var (now, port, source, destination) in events)
{
table[source] = (port, now);
int? output = destination == "all" ? null : Lookup(destination, now);
string decision = output == port ? "filter" : output is not null ? "forward"
: destination == "all" ? "broadcast" : "unknown";
var ports = Enumerable.Range(1, 5).Where(p =>
output is not null ? p == output && output != port : p != port);
string shown = string.Join(",", ports);
string result = shown == "" ? "none" : shown;
Console.WriteLine($"t={now} {source}->{destination} {decision} ports={result}");
}
foreach (string source in table.Keys.Order(StringComparer.Ordinal))
{
var (port, seen) = table[source];
string usable = (Lookup(source, 400) is not null).ToString().ToLowerInvariant();
Console.WriteLine($"{source} port={port} age={400 - seen} usable={usable}");
} Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
static int ports[5], seen[5];
static int lookup(int source, int now) {
return source >= 0 && ports[source] && now - seen[source] < 300
? ports[source]
: 0;
}
int main(void) {
const int times[] = {0, 1, 2, 3, 4, 5, 20, 400},
ingress[] = {1, 2, 1, 3, 4, 4, 5, 3};
const int sources[] = {0, 1, 0, 2, 3, 4, 1, 2},
destinations[] = {1, 0, 1, -1, 2, 3, 0, 0};
for (int i = 0; i < 8; i++) {
const int now = times[i], port = ingress[i], src = sources[i],
dst = destinations[i];
ports[src] = port;
seen[src] = now;
const int out = lookup(dst, now);
const char *decision = out == port ? "filter"
: out ? "forward"
: dst < 0 ? "broadcast"
: "unknown";
if (dst < 0)
printf("t=%d %c->all %s ports=", now, 'A' + src, decision);
else
printf("t=%d %c->%c %s ports=", now, 'A' + src, 'A' + dst, decision);
int count = 0;
for (int p = 1; p <= 5; p++)
if (out ? p == out && out != port : p != port) {
printf("%s%d", count++ ? "," : "", p);
}
if (!count)
printf("none");
putchar('\n');
}
for (int i = 0; i < 5; i++)
printf("%c port=%d age=%d usable=%s\n", 'A' + i, ports[i], 400 - seen[i],
lookup(i, 400) ? "true" : "false");
return 0;
} Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
/** @param array<string, array{int, int}> $table */
function lookup(array $table, string $source, int $now): ?int
{
$entry = $table[$source] ?? null;
return $entry !== null && $now - $entry[1] < 300 ? $entry[0] : null;
}
$table = [];
$events = [[0,1,'A','B'],[1,2,'B','A'],[2,1,'A','B'],[3,3,'C','all'],
[4,4,'D','C'],[5,4,'E','D'],[20,5,'B','A'],[400,3,'C','A']];
foreach ($events as [$now, $port, $source, $destination]) {
$table[$source] = [$port, $now];
$out = $destination === 'all' ? null : lookup($table, $destination, $now);
$decision = $out === $port ? 'filter' : ($out !== null ? 'forward'
: ($destination === 'all' ? 'broadcast' : 'unknown'));
$ports = array_filter(range(1, 5), static fn(int $p): bool
=> $out !== null ? $p === $out && $out !== $port : $p !== $port);
$shown = implode(',', $ports) ?: 'none';
echo "t={$now} {$source}->{$destination} {$decision} ports={$shown}\n";
}
ksort($table);
foreach ($table as $source => [$port, $seen]) {
$age = 400 - $seen;
$usable = lookup($table, $source, 400) !== null ? 'true' : 'false';
echo "{$source} port={$port} age={$age} usable={$usable}\n";
} Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::BTreeMap;
fn lookup(table: &BTreeMap<&str, (i32, i32)>, source: &str, now: i32) -> Option<i32> {
table
.get(source)
.and_then(|&(port, seen)| (now - seen < 300).then_some(port))
}
fn main() {
let mut table = BTreeMap::new();
let events = [
(0, 1, "A", "B"),
(1, 2, "B", "A"),
(2, 1, "A", "B"),
(3, 3, "C", "all"),
(4, 4, "D", "C"),
(5, 4, "E", "D"),
(20, 5, "B", "A"),
(400, 3, "C", "A"),
];
for (now, port, source, destination) in events {
table.insert(source, (port, now));
let out = if destination == "all" {
None
} else {
lookup(&table, destination, now)
};
let decision = match out {
Some(p) if p == port => "filter",
Some(_) => "forward",
_ if destination == "all" => "broadcast",
_ => "unknown",
};
let ports = (1..=5)
.filter(|&p| match out {
Some(value) => p == value && value != port,
None => p != port,
})
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(",");
let shown = if ports.is_empty() { "none" } else { &ports };
println!("t={now} {source}->{destination} {decision} ports={shown}");
}
for (source, (port, seen)) in &table {
let age = 400 - seen;
let usable = lookup(&table, source, 400).is_some();
println!("{source} port={port} age={age} usable={usable}");
}
} Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
const table = new Map<string, [number, number]>();
function lookup(address: string, now: number): number | null {
const entry = table.get(address);
return entry !== undefined && now - entry[1] < 300 ? entry[0] : null;
}
const events: [number, number, string, string][] = [
[0, 1, "A", "B"],
[1, 2, "B", "A"],
[2, 1, "A", "B"],
[3, 3, "C", "all"],
[4, 4, "D", "C"],
[5, 4, "E", "D"],
[20, 5, "B", "A"],
[400, 3, "C", "A"],
];
for (const [now, port, source, destination] of events) {
table.set(source, [port, now]);
const out = destination === "all" ? null : lookup(destination, now);
const decision =
out === port
? "filter"
: out !== null
? "forward"
: destination === "all"
? "broadcast"
: "unknown";
const ports =
out === port ? [] : out !== null ? [out] : [1, 2, 3, 4, 5].filter((p) => p !== port);
console.log(`t=${now} ${source}->${destination} ${decision} ports=${ports.join(",") || "none"}`);
}
for (const source of [...table.keys()].sort()) {
const entry = table.get(source);
if (entry === undefined) continue;
const [port, seen] = entry;
console.log(`${source} port=${port} age=${400 - seen} usable=${lookup(source, 400) !== null}`);
} Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"sort"
"strings"
)
type entry struct{ port, seen int }
type event struct {
now, port int
source, destination string
}
func lookup(table map[string]entry, source string, now int) (int, bool) {
e, ok := table[source]
return e.port, ok && now-e.seen < 300
}
func main() {
table := map[string]entry{}
events := []event{{0, 1, "A", "B"}, {1, 2, "B", "A"}, {2, 1, "A", "B"}, {3, 3, "C", "all"},
{4, 4, "D", "C"}, {5, 4, "E", "D"}, {20, 5, "B", "A"}, {400, 3, "C", "A"}}
for _, e := range events {
table[e.source] = entry{e.port, e.now}
out, known := lookup(table, e.destination, e.now)
if e.destination == "all" {
known = false
}
decision := "unknown"
switch {
case known && out == e.port:
decision = "filter"
case known:
decision = "forward"
case e.destination == "all":
decision = "broadcast"
}
ports := []string{}
for p := 1; p <= 5; p++ {
if (known && p == out && out != e.port) || (!known && p != e.port) {
ports = append(ports, fmt.Sprint(p))
}
}
shown := strings.Join(ports, ",")
if shown == "" {
shown = "none"
}
fmt.Printf("t=%d %s->%s %s ports=%s\n", e.now, e.source, e.destination, decision, shown)
}
keys := []string{}
for source := range table {
keys = append(keys, source)
}
sort.Strings(keys)
for _, source := range keys {
e := table[source]
_, usable := lookup(table, source, 400)
fmt.Printf("%s port=%d age=%d usable=%t\n", source, e.port, 400-e.seen, usable)
}
} Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
var table = [String: (Int, Int)]()
func lookup(_ source: String, now: Int) -> Int? {
guard let entry = table[source], now - entry.1 < 300 else { return nil }
return entry.0
}
let events = [
(0, 1, "A", "B"), (1, 2, "B", "A"), (2, 1, "A", "B"), (3, 3, "C", "all"),
(4, 4, "D", "C"), (5, 4, "E", "D"), (20, 5, "B", "A"), (400, 3, "C", "A"),
]
for (now, port, source, destination) in events {
table[source] = (port, now)
let out = destination == "all" ? nil : lookup(destination, now: now)
let decision =
out == port
? "filter"
: out != nil
? "forward"
: destination == "all" ? "broadcast" : "unknown"
let ports = (1...5).filter { p in out.map { p == $0 && $0 != port } ?? (p != port) }
.map { String($0) }.joined(separator: ",")
print("t=\(now) \(source)->\(destination) \(decision) ports=\(ports.isEmpty ? "none":ports)")
}
for source in table.keys.sorted() {
guard let entry = table[source] else { continue }
print("\(source) port=\(entry.0) age=\(400-entry.1) usable=\(lookup(source,now:400) != nil)")
} Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
def lookup(table, source, now)
entry = table[source]
entry && now - entry[1] < 300 ? entry[0] : nil
end
table = {}
events = [[0, 1, 'A', 'B'], [1, 2, 'B', 'A'], [2, 1, 'A', 'B'], [3, 3, 'C', 'all'],
[4, 4, 'D', 'C'], [5, 4, 'E', 'D'], [20, 5, 'B', 'A'], [400, 3, 'C', 'A']]
events.each do |now, port, source, destination|
table[source] = [port, now]
output = destination == 'all' ? nil : lookup(table, destination, now)
decision = if output == port
'filter'
elsif output
'forward'
else
destination == 'all' ? 'broadcast' : 'unknown'
end
ports = (1..5).select do |value|
output ? value == output && output != port : value != port
end.join(',')
puts "t=#{now} #{source}->#{destination} #{decision} ports=#{ports.empty? ? 'none' : ports}"
end
table.keys.sort.each do |source|
port, seen = table[source]
puts "#{source} port=#{port} age=#{400 - seen} usable=#{!lookup(table, source, 400).nil?}"
end Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
val table = mutableMapOf<String, Pair<Int, Int>>()
fun lookup(
source: String,
now: Int,
): Int? = table[source]?.takeIf { now - it.second < 300 }?.first
data class Event(
val now: Int,
val port: Int,
val source: String,
val destination: String,
)
fun main() {
val events =
listOf(
Event(0, 1, "A", "B"),
Event(1, 2, "B", "A"),
Event(2, 1, "A", "B"),
Event(3, 3, "C", "all"),
Event(4, 4, "D", "C"),
Event(5, 4, "E", "D"),
Event(20, 5, "B", "A"),
Event(400, 3, "C", "A"),
)
for ((now, port, source, destination) in events) {
table[source] = port to now
val output = if (destination == "all") null else lookup(destination, now)
val decision =
when {
output == port -> "filter"
output != null -> "forward"
destination == "all" -> "broadcast"
else -> "unknown"
}
val ports =
(1..5)
.filter {
if (output != null) it == output && output != port else it != port
}.joinToString(",")
.ifEmpty { "none" }
println("t=$now $source->$destination $decision ports=$ports")
}
for (source in table.keys.sorted()) {
val (port, seen) = table.getValue(source)
println("$source port=$port age=${400 - seen} usable=${lookup(source,400) != null}")
}
} Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
final table = <String, (int, int)>{};
int? lookup(String source, int now) {
final entry = table[source];
return entry != null && now - entry.$2 < 300 ? entry.$1 : null;
}
void main() {
final events = [
(0, 1, 'A', 'B'),
(1, 2, 'B', 'A'),
(2, 1, 'A', 'B'),
(3, 3, 'C', 'all'),
(4, 4, 'D', 'C'),
(5, 4, 'E', 'D'),
(20, 5, 'B', 'A'),
(400, 3, 'C', 'A'),
];
for (final (now, port, source, destination) in events) {
table[source] = (port, now);
final output = destination == 'all' ? null : lookup(destination, now);
final decision = output == port
? 'filter'
: output != null
? 'forward'
: destination == 'all'
? 'broadcast'
: 'unknown';
final ports = [1, 2, 3, 4, 5]
.where(
(p) => output != null ? p == output && output != port : p != port,
)
.join(',');
print(
't=$now $source->$destination $decision ports=${ports.isEmpty ? 'none' : ports}',
);
}
final keys = table.keys.toList()..sort();
for (final source in keys) {
final entry = table[source];
if (entry == null) continue;
final (port, seen) = entry;
print(
'$source port=$port age=${400 - seen} usable=${lookup(source, 400) != null}',
);
}
} Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
t=0 A->B unknown ports=2,3,4,5 t=1 B->A forward ports=1 t=2 A->B forward ports=2 t=3 C->all broadcast ports=1,2,4,5 t=4 D->C forward ports=3 t=5 E->D filter ports=none t=20 B->A forward ports=1 t=400 C->A unknown ports=1,2,4,5 A port=1 age=398 usable=false B port=5 age=380 usable=false C port=3 age=0 usable=true D port=4 age=396 usable=false E port=4 age=395 usable=false
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 13 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 153 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 4 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 23 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 727 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 194 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 19 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Follow A entering port 1, then learn its source location before selecting ports 2, 3, 4 and 5 for the unknown B destination.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original first frame enters port 1 with source A and destination B.
- The table records the arrival port from A, before deciding where the frame leaves.
- B has no usable entry, so this stated learning model floods to every other eligible port.
Checkpoint
Check: A source address teaches the ingress port
A destination lookup selects forward, filter or flood
Read the decisions line by line:
- t = 0. The table is empty, so the switch learns A on port 1 and, knowing nothing about B, floods the frame to ports 2 to 5. C, D and E receive a frame that is not for them, see a destination address that is not theirs, and discard it.
- t = 1. B answers. The switch learns B on port 2 and already knows A, so the reply leaves by port 1 only. One exchange taught the switch both ends of the conversation.
- t = 2. A’s entry is refreshed, and the frame for B goes out of port 2 alone.
- t = 3. A broadcast from C, such as an ARP request asking who has an IP address, is always flooded, however much the switch knows.
- t = 4 and 5. D and E are both on port 4, through a small unmanaged switch under a desk. When E sends to D, the switch finds D on port 4, the port the frame arrived on, and Switch filter decision: A forwarding decision that selects no outgoing port when the usable destination entry points to the frame arrival port.: the small switch has already delivered it, and sending it back would only add a duplicate.
- t = 20. B has been unplugged from port 2 and plugged into port 5. Its first frame from the new port moves the entry at once.
- t = 400. A has sent nothing since t = 2, 398 seconds earlier, so its entry has aged out and the frame for A is flooded, as if A were new. C’s own entry had aged out too (C last spoke at t = 3), so the switch learns C afresh.
Learn from the source, flood the unknown, forward the known
Text description of the diagram
A sequence diagram with four columns: host A on port 1, the switch, host B on port 2 and host C on port 3. Time runs downwards.
- A sends a frame for B. The switch learns that A is on port 1, but it has no entry for B.
- The switch floods the frame: one copy goes to B and one to C, which drops it because it is not for C.
- B replies to A. The switch learns that B is on port 2 and already knows that A is on port 1, so it forwards the reply to port 1 only.
- A sends its next frame for B. The switch now knows B's port and forwards the frame to port 2 only; C sees nothing.
Flooding is not a failure. It is how a switch stays correct while it does not know something: every host still receives the frames meant for it, and the cost is copies on links that did not need them. As soon as the destination sends anything, it is learned, and the flooding for it stops.
Compare B’s reply on port 2 with E sending to D on port 4, where the destination is on the arrival side.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The reply learns B on port 2 and uses the known A entry on port 1.
- The new A-to-B frame can use one known egress port rather than flood.
- The stated D entry is on the same arrival port, so the E-to-D frame is filtered.
Checkpoint
Check: A destination lookup selects forward, filter or flood
The newest source location replaces the old entry
Each entry ties a MAC address, within one VLAN, to a port, with the time the address was last seen as a source.
Entries learned from traffic are dynamic and age out. Static entries are configured by an administrator
and stay; the Linux bridge command, for example, adds them with bridge fdb add and lists the table with
bridge fdb show.
A few rules follow from learning only from source addresses:
- One port can lead to many addresses. A port connected to another switch or to a virtualisation server learns every address behind it. The table says which way to send, not what is plugged in.
- A host that only receives is never learned. If a server receives a one-way stream and sends nothing back on that network, every switch keeps flooding the stream. Cisco’s notes on unicast flooding name asymmetric routing as one of the two most common causes in campus networks: replies come back through a different router, so the switch on the outbound path never sees the server’s own address as a source.
- A group address is never a valid source. A frame can be addressed to a group, but it is always sent by one interface, so a frame whose source address has the group bit set is malformed. The Linux bridge drops such frames before it learns anything from them.
- A move takes effect at once. The next frame from the host’s new port updates the entry. An entry marked sticky is the exception: on Linux, such an entry does not change its port through learning.
The table is also per VLAN: the same MAC address may appear in two VLANs with two different ports, and flooding reaches only the ports of the frame’s VLAN. The VLAN lesson of this module explains how a switch knows a frame’s VLAN.
Follow B from port 2 to port 5 at t = 20, then read the one updated location and timestamp.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original entry describes B at port 2.
- Seeing B as a source on port 5 replaces its prior location immediately in this learning model.
- The replacement has port 5 and the new source timestamp rather than keeping two live locations.
Checkpoint
Check: The newest source location replaces the old entry
An entry is usable only before its ageing boundary
An entry lives for the Forwarding entry age: The elapsed time since the source observation that last refreshed a dynamic forwarding entry, compared with its configured ageing rule. after the address was last seen as a source, and every frame from the host resets its timer. Linux bridges and Cisco Catalyst 9300 switches both default to 300 seconds; Cisco accepts 10 to 1,000,000 seconds. Linux treats an entry as gone at the exact moment its age reaches the ageing time, the rule the exercise below uses too.
Why expire anything? A host can disappear from one port and reappear on another without the first port going down: a virtual machine that migrates to another server, or a laptop moved from a desk switch to a wall socket. Until the host sends a frame from its new place, the old entry keeps sending its traffic the wrong way. When a port’s link goes down, the problem is smaller: a Linux bridge deletes every entry learned on a port it disables, so frames for those hosts are flooded and reach them wherever they now are.
The ageing time is therefore a trade-off. Short ageing makes the switch forget quiet hosts and flood frames for them; long ageing leaves a host that moved silently cut off for longer. Here is the trade-off with numbers, for a sensor that sends one frame every 400 seconds while a client sends it a frame every 10 seconds:
"""Choosing an ageing time: flooding for a quiet host against the wait after a host moves silently."""
SENSOR_EVERY = 400 # the sensor sends one frame every 400 s, at t = 0, 400, 800, ...
CLIENT_EVERY = 10 # a client sends the sensor a frame every 10 s, at t = 5, 15, 25, ...
HOUR = 3600
print(f"{'ageing':>7} {'flooded per hour':>16} {'worst wait after a silent move':>30}")
for ageing in (15, 60, 300, 1800):
flooded = 0
for t in range(5, HOUR, CLIENT_EVERY):
last_seen = t - t % SENSOR_EVERY # the sensor's latest frame before t
if t - last_seen >= ageing: # its entry has aged out: the switch floods
flooded += 1
print(f"{ageing:>5} s {flooded:>9} of {HOUR // CLIENT_EVERY} {ageing:>28} s") Output
ageing flooded per hour worst wait after a silent move 15 s 351 of 360 15 s 60 s 306 of 360 60 s 300 s 90 of 360 300 s 1800 s 0 of 360 1800 s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ageing_tradeoff.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With the default 300 seconds, the sensor’s entry is valid for the first 300 seconds of each 400-second cycle, so the client’s frames in the remaining 100 seconds are flooded: 90 of 360 an hour. An ageing time longer than the sensor’s 400-second interval would stop the flooding completely, at the price of a much longer worst case after a silent move. Spanning tree, the subject of the next lesson, uses this trade-off on purpose: while the network’s topology is changing, a Linux bridge ages its entries after the forward delay, 15 seconds by default, instead of 300.
Start from A’s last frame at t = 2 and find the 300-second boundary at t = 302 before reading the t = 400 decision.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- A last refreshed its original entry at time 2 seconds.
- With ageing 300, the original entry is usable only while elapsed time is less than 300.
- The silent A entry is too old, so the stated C-to-A frame floods to other eligible ports.
Checkpoint
Check: An entry is usable only before its ageing boundary
Each switch learns only where a source arrives
In a network of several switches, each one runs the same algorithm on its own table and knows nothing about the others. To trace a frame, start at the switch where it enters and apply the three steps; whenever a switch forwards or floods out of a port that leads to another switch, continue at that switch, with that link as the arrival port.
The next program wires three switches in a row. S1 has host A on port 1, host B on port 2 and its port 3 linked to port 1 of S2. S2 has host C on port 2 and its port 3 linked to port 1 of S3, which has host D on port 2 and host E on port 3. Every table starts empty, and five frames are sent:
"""Five frames through three learning switches in a row: which links carry each one, and what each switch learns."""
from collections import deque
# Who is at the other end of each switch port: a host, or (switch, port) of the next switch.
WIRING = {
"S1": {1: "A", 2: "B", 3: ("S2", 1)},
"S2": {1: ("S1", 3), 2: "C", 3: ("S3", 1)},
"S3": {1: ("S2", 3), 2: "D", 3: "E"},
}
HOME = {"A": ("S1", 1), "B": ("S1", 2), "C": ("S2", 2), "D": ("S3", 2), "E": ("S3", 3)}
tables = {s: {} for s in WIRING} # switch -> {host: port}
def send(src, dst):
"""Deliver one frame; return the hosts that got a copy and the number of links it crossed."""
got, links = [], 1 # the sender's own cable to its switch
queue = deque([HOME[src]]) # (switch, ingress port) still to process
while queue:
switch, port = queue.popleft()
tables[switch][src] = port # learn the source
known = tables[switch].get(dst)
if dst == "all" or known is None:
out = [p for p in WIRING[switch] if p != port] # flood
else:
out = [] if known == port else [known] # forward or filter
for p in out:
links += 1
peer = WIRING[switch][p]
if isinstance(peer, tuple):
queue.append(peer)
else:
got.append(peer)
return got, links
for src, dst in [("A", "E"), ("E", "A"), ("C", "B"), ("D", "all"), ("A", "E")]:
got, links = send(src, dst)
extra = [h for h in got if h != dst] if dst != "all" else []
print(f"{src} -> {dst:<3} links crossed: {links} copies to {','.join(sorted(got)):<9} unwanted: {len(extra)}")
print()
for switch, table in tables.items():
print(switch, " ".join(f"{h}:{p}" for h, p in sorted(table.items()))) Output
A -> E links crossed: 7 copies to B,C,D,E unwanted: 3 E -> A links crossed: 4 copies to A unwanted: 0 C -> B links crossed: 7 copies to A,B,D,E unwanted: 3 D -> all links crossed: 7 copies to A,B,C,E unwanted: 0 A -> E links crossed: 4 copies to E unwanted: 0 S1 A:1 C:3 D:3 E:3 S2 A:1 C:2 D:3 E:3 S3 A:1 C:1 D:2 E:3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_switches.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Python 3.14 · main.py
from collections import deque
net = [["A", "B", (1, 0)], [(0, 2), "C", (2, 0)], [(1, 2), "D", "E"]]
host = {"A": (0, 0), "B": (0, 1), "C": (1, 1), "D": (2, 1), "E": (2, 2)}
fdb: list[dict[str, int]] = [{}, {}, {}]
def send(src: str, dst: str) -> tuple[list[str], int]:
got: list[str] = []
links = 1
queue = deque([host[src]])
while queue:
switch, ingress = queue.popleft()
fdb[switch][src] = ingress
known = fdb[switch].get(dst)
out = (
[p for p in range(3) if p != ingress]
if known is None
else []
if known == ingress
else [known]
)
for port in out:
links += 1
peer = net[switch][port]
if isinstance(peer, tuple):
queue.append(peer)
else:
got.append(peer)
return got, links
for src, dst in [
("A", "E"),
("E", "A"),
("C", "B"),
("D", "all"),
("A", "E"),
]:
got, links = send(src, dst)
extra = sum(host != dst for host in got) if dst != "all" else 0
print(f"{src}->{dst} links={links} copies={','.join(sorted(got))} unwanted={extra}")
for switch, table in enumerate(fdb, 1):
print(
f"S{switch} "
+ ",".join(f"{host}:{port + 1}" for host, port in sorted(table.items()))
) Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const net = [
["A", "B", [1, 0]],
[[0, 2], "C", [2, 0]],
[[1, 2], "D", "E"],
];
const host = new Map([
["A", [0, 0]],
["B", [0, 1]],
["C", [1, 1]],
["D", [2, 1]],
["E", [2, 2]],
]);
const fdb = [new Map(), new Map(), new Map()];
function send(src, dst) {
const got = [],
queue = [host.get(src)];
let links = 1;
for (let at = 0; at < queue.length; at++) {
const [sw, ingress] = queue[at];
fdb[sw].set(src, ingress);
const known = fdb[sw].get(dst);
const out =
known === undefined
? [0, 1, 2].filter((p) => p !== ingress)
: known === ingress
? []
: [known];
for (const port of out) {
links++;
const peer = net[sw][port];
if (Array.isArray(peer)) queue.push(peer);
else got.push(peer);
}
}
return [got, links];
}
const frames = [
["A", "E"],
["E", "A"],
["C", "B"],
["D", "all"],
["A", "E"],
];
for (const [src, dst] of frames) {
const [got, links] = send(src, dst);
const extra = dst === "all" ? 0 : got.filter((h) => h !== dst).length;
const copies = got.sort().join(",");
console.log(`${src}->${dst} links=${links} copies=${copies} unwanted=${extra}`);
}
for (let sw = 0; sw < 3; sw++) {
const row = [...fdb[sw].keys()].sort().map((h) => `${h}:${fdb[sw].get(h) + 1}`);
console.log(`S${sw + 1} ${row.join(",")}`);
} Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
public class Main {
record Port(int sw, int ingress) {}
record Peer(String host, Port port) {}
static Peer host(String name) {
return new Peer(name, null);
}
static Peer link(int sw, int ingress) {
return new Peer(null, new Port(sw, ingress));
}
public static void main(String[] args) {
Peer[][] net = {
{host("A"), host("B"), link(1, 0)},
{link(0, 2), host("C"), link(2, 0)},
{link(1, 2), host("D"), host("E")}
};
var host =
Map.of(
"A",
new Port(0, 0),
"B",
new Port(0, 1),
"C",
new Port(1, 1),
"D",
new Port(2, 1),
"E",
new Port(2, 2));
List<HashMap<String, Integer>> fdb = List.of(new HashMap<>(), new HashMap<>(), new HashMap<>());
String[][] frames = {{"A", "E"}, {"E", "A"}, {"C", "B"}, {"D", "all"}, {"A", "E"}};
for (String[] frame : frames) {
String src = frame[0], dst = frame[1];
var got = new ArrayList<String>();
var queue = new ArrayDeque<Port>();
queue.add(host.get(src));
int links = 1;
while (!queue.isEmpty()) {
Port here = queue.remove();
fdb.get(here.sw()).put(src, here.ingress());
Integer known = fdb.get(here.sw()).get(dst);
for (int port = 0; port < 3; port++) {
boolean skip =
known != null ? port != known || known == here.ingress() : port == here.ingress();
if (skip) continue;
links++;
Peer peer = net[here.sw()][port];
if (peer.port() != null) queue.add(peer.port());
else got.add(peer.host());
}
}
int extra = dst.equals("all") ? 0 : (int) got.stream().filter(h -> !h.equals(dst)).count();
got.sort(String::compareTo);
System.out.printf(
"%s->%s links=%d copies=%s unwanted=%d%n", src, dst, links, String.join(",", got), extra);
}
for (int sw = 0; sw < 3; sw++) {
var row = new ArrayList<String>();
for (String hostName : new TreeSet<>(fdb.get(sw).keySet()))
row.add(hostName + ":" + (fdb.get(sw).get(hostName) + 1));
System.out.println("S" + (sw + 1) + " " + String.join(",", row));
}
}
} Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <map>
#include <print>
#include <string>
#include <utility>
#include <vector>
struct Port {
int sw;
int ingress;
};
struct Peer {
std::string host;
Port port;
};
int main() {
const Peer net[3][3] = {{{"A", {}}, {"B", {}}, {"", {1, 0}}},
{{"", {0, 2}}, {"C", {}}, {"", {2, 0}}},
{{"", {1, 2}}, {"D", {}}, {"E", {}}}};
const std::map<std::string, Port> host = {{"A", {0, 0}},
{"B", {0, 1}},
{"C", {1, 1}},
{"D", {2, 1}},
{"E", {2, 2}}};
std::map<std::string, int> fdb[3];
const std::vector<std::pair<std::string, std::string>> frames = {
{"A", "E"}, {"E", "A"}, {"C", "B"}, {"D", "all"}, {"A", "E"}};
for (const auto &[src, dst] : frames) {
std::vector<std::string> got;
std::vector<Port> queue = {host.at(src)};
int links = 1;
for (std::size_t at = 0; at < queue.size(); at++) {
const auto here = queue[at];
auto &table = fdb[here.sw];
table[src] = here.ingress;
const auto known = table.find(dst);
for (int p = 0; p < 3; p++) {
const bool skip =
known != table.end()
? p != known->second || known->second == here.ingress
: p == here.ingress;
if (skip)
continue;
links++;
const auto &peer = net[here.sw][p];
if (peer.host.empty())
queue.push_back(peer.port);
else
got.push_back(peer.host);
}
}
const auto extra =
dst == "all"
? 0
: std::count_if(got.begin(), got.end(),
[&](const auto &host) { return host != dst; });
std::sort(got.begin(), got.end());
std::string copies;
for (const auto &host : got) {
if (!copies.empty())
copies += ",";
copies += host;
}
std::println("{}->{} links={} copies={} unwanted={}", src, dst, links,
copies, extra);
}
for (int sw = 0; sw < 3; sw++) {
std::string row;
for (const auto &[host, p] : fdb[sw]) {
if (!row.empty())
row += ",";
row += host + ":" + std::to_string(p + 1);
}
std::println("S{} {}", sw + 1, row);
}
} Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
(string? Host, int Switch, int Port)[][] net = [
[("A",0,0),("B",0,0),(null,1,0)],
[(null,0,2),("C",0,0),(null,2,0)],
[(null,1,2),("D",0,0),("E",0,0)]];
Dictionary<string, (int Switch, int Port)> host = new()
{
["A"] = (0, 0),
["B"] = (0, 1),
["C"] = (1, 1),
["D"] = (2, 1),
["E"] = (2, 2)
};
Dictionary<string, int>[] fdb = [new(), new(), new()];
var frames = new[] { ("A", "E"), ("E", "A"), ("C", "B"), ("D", "all"), ("A", "E") };
foreach (var (src, dst) in frames)
{
List<string> got = new(); Queue<(int Switch, int Port)> queue = new();
queue.Enqueue(host[src]); int links = 1;
while (queue.TryDequeue(out var here))
{
fdb[here.Switch][src] = here.Port;
bool known = fdb[here.Switch].TryGetValue(dst, out int output);
for (int port = 0; port < 3; port++)
{
if (known ? port != output || output == here.Port : port == here.Port) continue;
links++; var peer = net[here.Switch][port];
if (peer.Host is null) queue.Enqueue((peer.Switch, peer.Port)); else got.Add(peer.Host);
}
}
int extra = dst == "all" ? 0 : got.Count(h => h != dst);
got.Sort(StringComparer.Ordinal);
string copies = string.Join(",", got);
Console.WriteLine($"{src}->{dst} links={links} copies={copies} unwanted={extra}");
}
for (int sw = 0; sw < 3; sw++)
{
var row = fdb[sw].Keys.Order(StringComparer.Ordinal).Select(h => $"{h}:{fdb[sw][h] + 1}");
Console.WriteLine($"S{sw + 1} {string.Join(",", row)}");
} Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
#include <string.h>
typedef struct {
int host, sw, port;
} Peer;
typedef struct {
int sw, port;
} Port;
int main(void) {
const Peer net[3][3] = {{{0, 0, 0}, {1, 0, 0}, {-1, 1, 0}},
{{-1, 0, 2}, {2, 0, 0}, {-1, 2, 0}},
{{-1, 1, 2}, {3, 0, 0}, {4, 0, 0}}};
const Port host[] = {{0, 0}, {0, 1}, {1, 1}, {2, 1}, {2, 2}};
const int frames[5][2] = {{0, 4}, {4, 0}, {2, 1}, {3, -1}, {0, 4}};
int fdb[3][5];
for (int s = 0; s < 3; s++)
for (int h = 0; h < 5; h++)
fdb[s][h] = -1;
for (int f = 0; f < 5; f++) {
const int src = frames[f][0], dst = frames[f][1];
Port queue[12] = {host[src]};
int end = 1, links = 1, got[5] = {0};
for (int at = 0; at < end; at++) {
const Port here = queue[at];
fdb[here.sw][src] = here.port;
const int known = dst < 0 ? -1 : fdb[here.sw][dst];
for (int p = 0; p < 3; p++) {
if (known >= 0 ? p != known || known == here.port : p == here.port)
continue;
links++;
const Peer next = net[here.sw][p];
if (next.host < 0)
queue[end++] = (Port){next.sw, next.port};
else
got[next.host]++;
}
}
int extra = 0;
char copies[16] = "";
int length = 0;
for (int h = 0; h < 5; h++)
if (got[h]) {
if (length)
copies[length++] = ',';
copies[length++] = (char)('A' + h);
if (dst >= 0 && h != dst)
extra += got[h];
}
if (dst < 0)
printf("%c->all", 'A' + src);
else
printf("%c->%c", 'A' + src, 'A' + dst);
printf(" links=%d copies=%s unwanted=%d\n", links, copies, extra);
}
for (int sw = 0; sw < 3; sw++) {
printf("S%d ", sw + 1);
int count = 0;
for (int h = 0; h < 5; h++)
if (fdb[sw][h] >= 0)
printf("%s%c:%d", count++ ? "," : "", 'A' + h, fdb[sw][h] + 1);
putchar('\n');
}
return 0;
} Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
$net = [['A','B',[1,0]],[[0,2],'C',[2,0]],[[1,2],'D','E']];
$host = ['A' => [0,0],'B' => [0,1],'C' => [1,1],'D' => [2,1],'E' => [2,2]];
$fdb = [[],[],[]];
foreach ([['A','E'],['E','A'],['C','B'],['D','all'],['A','E']] as [$src, $dst]) {
$got = [];
$queue = [$host[$src]];
$links = 1;
for ($at = 0; $at < count($queue); ++$at) {
[$sw, $ingress] = $queue[$at];
$fdb[$sw][$src] = $ingress;
$known = $fdb[$sw][$dst] ?? null;
for ($port = 0; $port < 3; ++$port) {
$skip = $known !== null ? $port !== $known || $known === $ingress : $port === $ingress;
if ($skip) {
continue;
}
++$links;
$peer = $net[$sw][$port];
if (is_array($peer)) {
$queue[] = $peer;
} else {
$got[] = $peer;
}
}
}
$extra = $dst === 'all' ? 0 : count(array_filter(
$got,
static fn(string $host): bool => $host !== $dst,
));
sort($got);
echo $src . '->' . $dst . ' links=' . $links . ' copies=' . implode(',', $got)
. ' unwanted=' . $extra . "\n";
}
foreach ($fdb as $sw => $table) {
ksort($table);
$row = [];
foreach ($table as $host => $port) {
$row[] = $host . ':' . ($port + 1);
}
echo 'S' . ($sw + 1) . ' ' . implode(',', $row) . "\n";
} Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::{BTreeMap, VecDeque};
#[derive(Clone, Copy)]
enum Peer {
Host(char),
Link(usize, usize),
}
fn main() {
use Peer::{Host as H, Link as L};
let net = [
[H('A'), H('B'), L(1, 0)],
[L(0, 2), H('C'), L(2, 0)],
[L(1, 2), H('D'), H('E')],
];
let host = BTreeMap::from([
('A', (0, 0)),
('B', (0, 1)),
('C', (1, 1)),
('D', (2, 1)),
('E', (2, 2)),
]);
let mut fdb: [BTreeMap<char, usize>; 3] = std::array::from_fn(|_| BTreeMap::new());
for (src, dst) in [('A', 'E'), ('E', 'A'), ('C', 'B'), ('D', '*'), ('A', 'E')] {
let mut got = Vec::new();
let mut links = 1;
let mut queue = VecDeque::new();
if let Some(&origin) = host.get(&src) {
queue.push_back(origin);
}
while let Some((sw, ingress)) = queue.pop_front() {
fdb[sw].insert(src, ingress);
let known = fdb[sw].get(&dst).copied();
for port in 0..3 {
if known.map_or(port == ingress, |p| port != p || p == ingress) {
continue;
}
links += 1;
match net[sw][port] {
H(host) => got.push(host),
L(s, p) => queue.push_back((s, p)),
}
}
}
let extra = if dst == '*' {
0
} else {
got.iter().filter(|&&h| h != dst).count()
};
got.sort();
let copies = got
.iter()
.map(char::to_string)
.collect::<Vec<_>>()
.join(",");
let dst = if dst == '*' {
"all".to_string()
} else {
dst.to_string()
};
println!("{src}->{dst} links={links} copies={copies} unwanted={extra}");
}
for (sw, table) in fdb.iter().enumerate() {
let row = table
.iter()
.map(|(h, p)| format!("{h}:{}", p + 1))
.collect::<Vec<_>>()
.join(",");
println!("S{} {row}", sw + 1);
}
} Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
type Peer = string | [number, number];
const net: Peer[][] = [
["A", "B", [1, 0]],
[[0, 2], "C", [2, 0]],
[[1, 2], "D", "E"],
];
const host = new Map<string, [number, number]>([
["A", [0, 0]],
["B", [0, 1]],
["C", [1, 1]],
["D", [2, 1]],
["E", [2, 2]],
]);
const fdb = [new Map<string, number>(), new Map<string, number>(), new Map<string, number>()];
function send(src: string, dst: string): [string[], number] {
const origin = host.get(src);
if (origin === undefined) throw new Error("unknown source");
const got: string[] = [],
queue: [number, number][] = [origin];
let links = 1;
for (let at = 0; at < queue.length; at++) {
const [sw, ingress] = queue[at];
fdb[sw].set(src, ingress);
const known = fdb[sw].get(dst);
const out =
known === undefined
? [0, 1, 2].filter((p) => p !== ingress)
: known === ingress
? []
: [known];
for (const port of out) {
links++;
const peer = net[sw][port];
if (typeof peer === "string") got.push(peer);
else queue.push(peer);
}
}
return [got, links];
}
const frames = [
["A", "E"],
["E", "A"],
["C", "B"],
["D", "all"],
["A", "E"],
];
for (const [src, dst] of frames) {
const [got, links] = send(src, dst);
const extra = dst === "all" ? 0 : got.filter((h) => h !== dst).length;
const copies = got.sort().join(",");
console.log(`${src}->${dst} links=${links} copies=${copies} unwanted=${extra}`);
}
for (let sw = 0; sw < 3; sw++) {
const row = [...fdb[sw].keys()].sort().map((h) => {
const port = fdb[sw].get(h);
if (port === undefined) throw new Error("missing entry");
return `${h}:${port + 1}`;
});
console.log(`S${sw + 1} ${row.join(",")}`);
} Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"sort"
"strings"
)
type port struct{ sw, ingress int }
type peer struct {
host string
port port
}
func main() {
net := [][]peer{{{"A", port{}}, {"B", port{}}, {"", port{1, 0}}},
{{"", port{0, 2}}, {"C", port{}}, {"", port{2, 0}}},
{{"", port{1, 2}}, {"D", port{}}, {"E", port{}}}}
host := map[string]port{"A": {0, 0}, "B": {0, 1}, "C": {1, 1}, "D": {2, 1}, "E": {2, 2}}
fdb := []map[string]int{{}, {}, {}}
frames := [][2]string{{"A", "E"}, {"E", "A"}, {"C", "B"}, {"D", "all"}, {"A", "E"}}
for _, frame := range frames {
src, dst := frame[0], frame[1]
got := []string{}
queue := []port{host[src]}
links := 1
for at := 0; at < len(queue); at++ {
here := queue[at]
fdb[here.sw][src] = here.ingress
output, known := fdb[here.sw][dst]
for p := 0; p < 3; p++ {
if known && (p != output || output == here.ingress) || !known && p == here.ingress {
continue
}
links++
next := net[here.sw][p]
if next.host == "" {
queue = append(queue, next.port)
} else {
got = append(got, next.host)
}
}
}
extra := 0
for _, host := range got {
if dst != "all" && host != dst {
extra++
}
}
sort.Strings(got)
fmt.Printf("%s->%s links=%d copies=%s unwanted=%d\n",
src, dst, links, strings.Join(got, ","), extra)
}
for sw, table := range fdb {
keys := []string{}
for host := range table {
keys = append(keys, host)
}
sort.Strings(keys)
row := []string{}
for _, host := range keys {
row = append(row, fmt.Sprintf("%s:%d", host, table[host]+1))
}
fmt.Printf("S%d %s\n", sw+1, strings.Join(row, ","))
}
} Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
enum Peer {
case host(String)
case link(Int, Int)
}
let net: [[Peer]] = [
[.host("A"), .host("B"), .link(1, 0)],
[.link(0, 2), .host("C"), .link(2, 0)], [.link(1, 2), .host("D"), .host("E")],
]
let host = ["A": (0, 0), "B": (0, 1), "C": (1, 1), "D": (2, 1), "E": (2, 2)]
var fdb = [[String: Int](), [String: Int](), [String: Int]()]
for (src, dst) in [("A", "E"), ("E", "A"), ("C", "B"), ("D", "all"), ("A", "E")] {
guard let origin = host[src] else { continue }
var queue = [origin]
var got = [String]()
var links = 1
var at = 0
while at < queue.count {
let (sw, ingress) = queue[at]
at += 1
fdb[sw][src] = ingress
let known = fdb[sw][dst]
for port in 0..<3 {
if known.map({ port != $0 || $0 == ingress }) ?? (port == ingress) { continue }
links += 1
switch net[sw][port] {
case .host(let host): got.append(host)
case .link(let sw, let ingress): queue.append((sw, ingress))
}
}
}
let extra = dst == "all" ? 0 : got.filter { $0 != dst }.count
let copies = got.sorted().joined(separator: ",")
print("\(src)->\(dst) links=\(links) copies=\(copies) unwanted=\(extra)")
}
for (sw, table) in fdb.enumerated() {
let row = table.keys.sorted().compactMap { host -> String? in
guard let port = table[host] else { return nil }
return "\(host):\(port+1)"
}.joined(separator: ",")
print("S\(sw+1) \(row)")
} Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
net = [['A', 'B', [1, 0]], [[0, 2], 'C', [2, 0]], [[1, 2], 'D', 'E']]
host = { 'A' => [0, 0], 'B' => [0, 1], 'C' => [1, 1], 'D' => [2, 1], 'E' => [2, 2] }
fdb = [{}, {}, {}]
[%w[A E], %w[E A], %w[C B], %w[D all], %w[A E]].each do |src, dst|
got = []
links = 1
queue = [host.fetch(src)]
until queue.empty?
switch, ingress = queue.shift
fdb[switch][src] = ingress
known = fdb[switch][dst]
(0..2).each do |port|
next if known ? port != known || known == ingress : port == ingress
links += 1
peer = net[switch][port]
peer.is_a?(Array) ? queue.push(peer) : got.push(peer)
end
end
extra = dst == 'all' ? 0 : got.count { |host| host != dst }
puts "#{src}->#{dst} links=#{links} copies=#{got.sort.join(',')} unwanted=#{extra}"
end
fdb.each_with_index do |table, switch|
row = table.keys.sort.map { |host| "#{host}:#{table.fetch(host) + 1}" }.join(',')
puts "S#{switch + 1} #{row}"
end Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
sealed interface Peer
data class Host(
val name: String,
) : Peer
data class Link(
val switch: Int,
val port: Int,
) : Peer
fun main() {
val net =
listOf(
listOf(Host("A"), Host("B"), Link(1, 0)),
listOf(Link(0, 2), Host("C"), Link(2, 0)),
listOf(Link(1, 2), Host("D"), Host("E")),
)
val host =
mapOf(
"A" to Link(0, 0),
"B" to Link(0, 1),
"C" to Link(1, 1),
"D" to Link(2, 1),
"E" to Link(2, 2),
)
val fdb = List(3) { mutableMapOf<String, Int>() }
val frames = listOf("A" to "E", "E" to "A", "C" to "B", "D" to "all", "A" to "E")
for ((src, dst) in frames) {
val got = mutableListOf<String>()
val queue = ArrayDeque<Link>()
queue.add(host.getValue(src))
var links = 1
while (queue.isNotEmpty()) {
val here = queue.removeFirst()
fdb[here.switch][src] = here.port
val known = fdb[here.switch][dst]
for (port in 0..2) {
val skip =
if (known != null) {
port != known || known == here.port
} else {
port == here.port
}
if (skip) continue
links++
when (val peer = net[here.switch][port]) {
is Host -> got.add(peer.name)
is Link -> queue.add(peer)
}
}
}
val extra = if (dst == "all") 0 else got.count { it != dst }
val copies = got.sorted().joinToString(",")
println("$src->$dst links=$links copies=$copies unwanted=$extra")
}
for ((sw, table) in fdb.withIndex()) {
val row = table.keys.sorted().joinToString(",") { "$it:${table.getValue(it) + 1}" }
println("S${sw + 1} $row")
}
} Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
sealed class Peer {}
final class Host extends Peer {
final String name;
Host(this.name);
}
final class Link extends Peer {
final int sw, port;
Link(this.sw, this.port);
}
void main() {
final net = <List<Peer>>[
[Host('A'), Host('B'), Link(1, 0)],
[Link(0, 2), Host('C'), Link(2, 0)],
[Link(1, 2), Host('D'), Host('E')],
];
final host = {
'A': Link(0, 0),
'B': Link(0, 1),
'C': Link(1, 1),
'D': Link(2, 1),
'E': Link(2, 2),
};
final fdb = List.generate(3, (_) => <String, int>{});
for (final (src, dst) in [
('A', 'E'),
('E', 'A'),
('C', 'B'),
('D', 'all'),
('A', 'E'),
]) {
final origin = host[src];
if (origin == null) continue;
final got = <String>[], queue = <Link>[origin];
var links = 1;
for (var at = 0; at < queue.length; at++) {
final here = queue[at];
fdb[here.sw][src] = here.port;
final known = fdb[here.sw][dst];
for (var port = 0; port < 3; port++) {
if (known != null
? port != known || known == here.port
: port == here.port)
continue;
links++;
switch (net[here.sw][port]) {
case Host(:final name):
got.add(name);
case final Link peer:
queue.add(peer);
}
}
}
final extra = dst == 'all' ? 0 : got.where((h) => h != dst).length;
got.sort();
print('$src->$dst links=$links copies=${got.join(',')} unwanted=$extra');
}
for (var sw = 0; sw < 3; sw++) {
final keys = fdb[sw].keys.toList()..sort();
final row = keys
.map((host) => '$host:${(fdb[sw][host] ?? -1) + 1}')
.join(',');
print('S${sw + 1} $row');
}
} Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
A->E links=7 copies=B,C,D,E unwanted=3 E->A links=4 copies=A unwanted=0 C->B links=7 copies=A,B,D,E unwanted=3 D->all links=7 copies=A,B,C,E unwanted=0 A->E links=4 copies=E unwanted=0 S1 A:1,C:3,D:3,E:3 S2 A:1,C:2,D:3,E:3 S3 A:1,C:1,D:2,E:3
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 70 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 157 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 28 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 728 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 201 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 20 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
- A to E is unknown everywhere, so each switch floods it: it crosses all 7 links, the sender’s included, and three hosts receive a copy they discard. On the way, every switch learns where A is.
- E to A follows the learned entries straight back: 4 links and no extra copies. The switches learned E’s position on the way.
- C to B is flooded through the whole network again, because B has never sent a frame and no table holds it.
- The broadcast from D reaches every host, as it must.
- A to E again now crosses only the 4 links of the path.
The final tables show how much each switch can know about hosts it is not connected to: S2 places A on its port 1 and E on its port 3, and S3 places A and C both behind its port 1. The tables are where the network’s knowledge lives, and each switch only knows the next step.
Follow all five original frames through S1, S2 and S3, recording each switch’s own learned ingress and delivered copies.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original S1–S2–S3 chain learns A separately at the ingress of each switch that receives the frame.
- E-to-A learns E along the returning path and uses the previously learned A entries.
- Keep all five original frames and their table updates rather than substituting a shorter trace.
Checkpoint
Check: Each switch learns only where a source arrives
Switch ports separate links and VLANs separate broadcasts
A hub is a repeater: it copies every bit that arrives on one port to all the others, without reading addresses. All its ports share one medium, so they form one collision domain, the set of interfaces whose transmissions can collide, and they must take turns using half-duplex CSMA/CD, as the Ethernet timing lesson described. A switch gives each port its own link: two hosts on different ports can send at the same moment, and a full-duplex port has no collisions at all. A switch floods a broadcast to eligible ports of its VLAN, which form one broadcast domain, the set of interfaces that broadcast reaches. A router ends a broadcast domain: RFC 1812 forbids an IPv4 router to forward a packet it received as a link-layer broadcast unless the packet is addressed to an IP multicast group.
| Criterion | Hub | Switch | Router |
|---|---|---|---|
| Works with | bits (physical layer) | frames and MAC addresses (link layer) | packets and IP addresses (network layer) |
| Sends a unicast frame to | every other port | one port once the address is learned; every other port before | the next hop on the best route |
| Collision domains | one for all its ports | one per port (none on full-duplex links) | one per interface |
| Broadcast domains | one | one per VLAN | one per interface: broadcasts stop here |
| When to choose | Nowhere in a new network; you may meet one in old installations | Connecting hosts inside one network or VLAN | Connecting different networks or VLANs to each other |
The difference is easy to measure. The program sends the same 2,000 random unicast frames between 8 hosts through a hub and through a learning switch, and counts the copies that reach a host they were not meant for:
"""The same 2,000 unicast frames between 8 hosts, through a hub and through a learning switch."""
import random
HOSTS = 8 # host h sits on port h
rng = random.Random(2026)
frames = [tuple(rng.sample(range(HOSTS), 2)) for _ in range(2000)] # (source, destination)
def deliver(frames, learning):
"""Copies sent out of each port, copies that reached a host they were not for, and frames flooded."""
out = [0] * HOSTS
unwanted = floods = 0
table = {} # host -> port (here host h is always on port h)
for src, dst in frames:
if learning:
table[src] = src
if learning and dst in table:
ports = [table[dst]]
else: # a hub repeats every frame; a switch floods what it cannot place
ports = [p for p in range(HOSTS) if p != src]
floods += 1
for p in ports:
out[p] += 1
unwanted += p != dst
return out, unwanted, floods
for name, learning in (("hub", False), ("switch", True)):
out, unwanted, floods = deliver(frames, learning)
print(f"{name:<6} sent to every port: {floods:>4} copies: {sum(out):>5} "
f"unwanted: {unwanted:>5} busiest port: {max(out)}")
seen = set()
for n, (src, _) in enumerate(frames, start=1):
seen.add(src)
if len(seen) == HOSTS:
print(f"all {HOSTS} hosts had sent a frame by frame {n}: from then on the switch knew every destination")
break Output
hub sent to every port: 2000 copies: 14000 unwanted: 12000 busiest port: 1773 switch sent to every port: 14 copies: 2084 unwanted: 84 busiest port: 281 all 8 hosts had sent a frame by frame 32: from then on the switch knew every destination
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hub_vs_switch.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The hub delivered 12,000 unwanted copies, six for every frame. The switch flooded only 14 frames, all of them before it had heard from every host, and after frame 32 every frame went to exactly one port. The busiest switch port carried 281 frames, against 1,773 on the hub, and on the hub all of those shared one collision domain.
A worked count. A router has two interfaces. The first goes to a switch with 10 PCs; the second to a hub with 5 PCs. On the switch side, every switch port in use is a collision domain: 10 PC ports plus the port to the router make 11. The hub, its 5 PCs and the router interface on that side share 1 more, so there are 12 collision domains. The router separates the two sides, so there are 2 broadcast domains.
Count the original seventeen active switch links and one shared hub segment, while keeping full-duplex collisions separate from the conventional domain count.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original switch has seventeen active point-to-point port links.
- The traditional hub segment is one shared collision domain.
- The conventional per-link count does not mean collisions happen on full-duplex Ethernet.
Checkpoint
Check: Switch ports separate links and VLANs separate broadcasts
A finite table can stop learning new destinations
A hardware switch has a finite forwarding-table capacity, with admission or replacement behavior that depends on
its implementation. A Linux bridge’s fdb_max_learned setting can cap learned entries; 0 disables that configured
cap, while memory and other resource limits still exist. At the configured cap, new learning can stop. Under the
stated unknown-unicast flood policy, destinations without usable entries receive copies on eligible VLAN ports.
Normal growth rarely fills a table, so Cisco’s notes on unicast flooding point at the other cause: a single host generating frames from a stream of different source addresses, which shows up as most of the table’s addresses sitting behind the same port. The defence is to limit how many addresses each untrusted port may teach the switch, the feature called port security, which the VLAN and switch security lesson of this module covers with the other switch protections.
MAC Address Lookup Look up the maker of an address you find in a MAC table, or check whether it is a randomised local address.Commands for a real switch
On a Linux bridge, bridge fdb show br br0 lists the table, bridge link set dev eth1 learning off stops one port
from learning, and ip link set dev br0 type bridge ageing_time 600 sets the ageing time in seconds. On a Cisco
Catalyst switch, mac address-table aging-time sets the same timer.
Switching in GATE CS
The data link layer of Section 10 of the GATE 2027 Computer Science syllabus lists Ethernet; the 2026 syllabus named Ethernet bridging explicitly, which is the subject of this module, so papers written for it are good practice. Counting collision and broadcast domains, as in the worked count above, is a quick exam question worth practising.
The CCNA 200-301 exam topics also name these ideas under switching concepts: MAC learning and aging, frame switching, frame flooding and the MAC address table.
Subtract forty entries from the stated 16,000 slots, then divide the 15,960 remaining slots by 10,000 new sources per second.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The owned lab model assumes a fixed 16,000-entry learning capacity.
- Forty existing entries leave 15,960 available slots under the stated no-expiry calculation.
- A full table outcome depends on implementation policy; the arithmetic alone does not prove every device handles capacity identically.
Checkpoint
Check: A finite table can stop learning new destinations
Exercise
Exercise · Medium · Python
Build a learning switch with ageing
Complete the class LearningSwitch in switch.py. Addresses are lower-case text with colons, such as 00:00:5e:00:53:0a, and times are seconds.
lookup(mac, now) returns the port on which mac was last seen as a source address. It returns None if the address was never seen, or if its entry has aged out: an entry recorded at time t is used while now - t is less than self.ageing, and from now - t == self.ageing on it counts as gone.
receive(port, src, dst, now) handles one frame that arrived on port and returns the ports it leaves by, as a sorted list:
- If
srcis a group address (the lowest bit of its first byte is 1, as in01:00:5e:90:10:01or the broadcast address), the frame is invalid: return[]and learn nothing. - Otherwise learn first: record that
srcis onportat timenow, replacing any older entry (a host that moved is found at its new port at once). - Then decide by
dst:- a group address (broadcast or multicast) is flooded: every port except the one the frame arrived on;
- a unicast address that
lookupfinds on another port is forwarded to that port alone; - a unicast address that
lookupfinds on the arrival port is filtered: return[]; - a unicast address that
lookupdoes not find (unknown or aged out) is flooded.
The sample tests replay six short traces: a first frame and its reply, broadcast and multicast, two hosts behind one port, a host that moves, the exact moment an entry ages out, and an invalid group source address.
Starter code · switch.py
BROADCAST = "ff:ff:ff:ff:ff:ff"
class LearningSwitch:
"""A learning switch with numbered ports and an ageing time in seconds."""
def __init__(self, ports, ageing=300):
self.ports = sorted(ports)
self.ageing = ageing
self.table = {} # MAC address -> (port, time it was last seen as a source)
def lookup(self, mac, now):
"""Return the port where mac was last seen, or None if it is unknown or its entry has aged out."""
# Replace this line with your code.
return None
def receive(self, port, src, dst, now):
"""Learn from one frame that arrived on port, and return the sorted list of ports it is sent out of."""
# Replace this line with your code.
return [] The sample tests · test_switch.py
from switch import BROADCAST, LearningSwitch
A, B, C, D, E = (f"00:00:5e:00:53:0{x}" for x in "abcde")
GROUP = "01:00:5e:90:10:01"
def test_first_frame_and_reply():
"""floods a frame for an unknown host, then forwards the reply and the next frame"""
sw = LearningSwitch([1, 2, 3, 4])
assert sw.receive(1, A, B, 0) == [2, 3, 4]
assert sw.lookup(A, 0) == 1 and sw.lookup(B, 0) is None
assert sw.receive(2, B, A, 1) == [1]
assert sw.receive(1, A, B, 2) == [2]
def test_broadcast_and_multicast():
"""floods broadcast and multicast frames even when the switch knows other hosts"""
sw = LearningSwitch([4, 1, 3, 2])
sw.receive(1, A, B, 0)
sw.receive(2, B, A, 0)
assert sw.receive(3, C, BROADCAST, 1) == [1, 2, 4]
assert sw.receive(2, B, GROUP, 2) == [1, 3, 4]
assert sw.lookup(C, 2) == 3
def test_two_hosts_behind_one_port():
"""filters a frame whose destination is on the port it came from"""
sw = LearningSwitch([1, 2, 3, 4])
assert sw.receive(4, D, C, 0) == [1, 2, 3]
assert sw.receive(4, E, D, 1) == []
assert sw.receive(1, A, E, 2) == [4]
def test_host_moves():
"""moves an entry to the port where its address shows up next"""
sw = LearningSwitch([1, 2, 3, 4, 5])
sw.receive(2, B, A, 0)
sw.receive(1, A, B, 1)
assert sw.receive(5, B, A, 20) == [1]
assert sw.lookup(B, 20) == 5
assert sw.receive(1, A, B, 21) == [5]
def test_ageing_boundary():
"""keeps an entry until ageing seconds after it was last refreshed, and not one moment longer"""
sw = LearningSwitch([1, 2, 3], ageing=300)
sw.receive(1, A, B, 10)
assert sw.lookup(A, 309) == 1
assert sw.lookup(A, 310) is None
sw.receive(1, A, B, 200)
assert sw.lookup(A, 499) == 1
assert sw.receive(2, B, A, 499) == [1]
assert sw.receive(3, C, A, 500) == [1, 2]
short = LearningSwitch([1, 2, 3], ageing=15)
short.receive(1, A, B, 0)
assert short.receive(2, B, A, 14) == [1]
assert short.receive(2, B, A, 15) == [1, 3]
def test_group_source_is_dropped():
"""drops a frame from a group source address and learns nothing from it"""
sw = LearningSwitch([1, 2, 3])
assert sw.receive(1, GROUP, A, 0) == []
assert sw.receive(2, BROADCAST, A, 0) == []
assert sw.lookup(GROUP, 0) is None and sw.lookup(BROADCAST, 0) is None
assert sw.receive(3, A, GROUP, 1) == [1, 2] A hint
int(mac[:2], 16) & 1 is 1 for a group address and 0 for a unicast one. In lookup, take the entry with self.table.get(mac): it is (port, seen) or None, and it is still fresh while now - seen < self.ageing. In receive, the flood list is [p for p in self.ports if p != port].
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practise the stated switching contracts
Practice that adapts to you
Practise this lesson
13 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A monitoring PC on an access port keeps capturing a large unicast stream between two servers it has nothing to do with, and every port of that VLAN carries it. The servers' replies return through a different router from their requests. Explain why the switches flood the stream, how you would confirm it, and two ways to stop it.
-
Fresher to mid
A lab switch with a 16,000-entry MAC address table serves 40 PCs. A faulty test program on one PC starts sending frames from a new random source address 10,000 times a second. How long until the table is full, what do the other users notice, and which switch settings would have limited the damage?
What does a switch do with a frame whose destination MAC address is not in its table? Under the stated flood policy, a copy goes out of every eligible forwarding port in the frame’s VLAN except ingress. Before deciding, the switch has already learned the frame’s source address on the arrival port, so when the destination answers, the reply leaves by that one port and the destination is learned in turn. From then on, frames between the two hosts use a single port each way. Broadcasts, multicast frames the switch has no group information for, and unicast frames whose entry has aged out are flooded the same way. Hosts that are not the destination simply discard their copies.
How many collision domains and broadcast domains does a 24-port switch create with all 24 ports in use? With all ports in one VLAN, the conventional count is 24 collision domains, one per port, and one broadcast domain. Hosts on different ports can send at the same moment without colliding, and on full-duplex links there are no collisions at all. A broadcast, though, is flooded to every port, so all 24 ports form one broadcast domain until a router or VLANs split it. A hub with 24 ports, by contrast, would be one collision domain.
Key takeaways
- A switch learns from source addresses: each frame records which port its sender is behind, and the timer of a known address restarts.
- It forwards a frame for a known address out of one port, filters a frame whose destination is on the arrival port, and floods broadcasts, unknown group addresses, and unicast frames for unknown or aged-out addresses.
- Entries age out, 300 seconds by default on a Linux bridge and on Catalyst 9300 switches: shorter ageing floods more, longer ageing leaves a silently moved host unreachable for longer.
- In a multi-switch network each switch applies the same rules to its own table; trace a frame hop by hop.
- Every switch port is its own collision domain; a broadcast domain ends at a router or a VLAN boundary.
- At a configured cap that stops learning, unknown unicast follows the VLAN’s flooding policy; hardware admission and replacement policies vary, and per-port address limits can contain excess learning.
Terms in this lesson
- Forwarding entry age
- The elapsed time since the source observation that last refreshed a dynamic forwarding entry, compared with its configured ageing rule.
- Source address learning
- The update that records the port and time where a valid source address was most recently observed in a bridge forwarding context.
- Switch filter decision
- A forwarding decision that selects no outgoing port when the usable destination entry points to the frame arrival port.
References
- IEEE 802.1Q-2022: Bridges and Bridged Networks (IEEE Standards Association)
- IEEE 802.3-2022: IEEE Standard for Ethernet (IEEE Standards Association)
- Ethernet Bridging (Linux kernel documentation) (The Linux Kernel documentation)
- bridge(8): show and manipulate bridge addresses and devices (iproute2 7.2.0) (iproute2 project (Debian manpages))
- ip-link(8): network device configuration, bridge options (iproute2 7.2.0) (iproute2 project (Debian manpages))
- Linux source: net/bridge/br_fdb.c (learning, ageing and the learned-entry limit) (The Linux kernel project)
- Linux source: net/bridge/br_input.c (frames with an invalid source address are dropped) (The Linux kernel project)
- Linux source: net/bridge/br_stp_if.c (entries of a disabled port are deleted) (The Linux kernel project)
- Unicast Flooding in Switched Campus Networks (Cisco)
- Catalyst 9300 Security Configuration Guide: Port Security (MAC address table ageing defaults) (Cisco)
- RFC 1812: Requirements for IP Version 4 Routers (section 5.3.4, link layer broadcasts) (RFC Editor (IETF))
- RFC 9542: IANA Considerations and IETF Protocol and Documentation Usage for IEEE 802 Parameters (RFC Editor (IETF))
- CCNA 200-301 v1.1 exam topics (Cisco)
- GATE 2027 syllabus: Computer Science and Information Technology (IIT Madras)
- GATE 2026 syllabus: Computer Science and Information Technology (IIT Guwahati)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress