Architecture
The five-stage pipeline every connection flows through, and who owns each stage.
Every connection through Xray crosses the same five stages: an inbound accepts and decodes it, the dispatcher sniffs and accounts for it, the router picks a tag for it, an outbound encodes it again, and a transport puts it on the wire. Each stage is a separate component wired together at startup — understanding the hand-offs makes every other page on this site easier to read.
Everything is a feature in one registry
An Xray Instance is just a list of features plus a dependency resolver — there is no hardcoded pipeline object. core.New walks the config, instantiates each app (dispatcher, router, stats, …) and appends it to Instance.features. Components declare what they need with core.RequireFeatures and a typed callback; the callback fires only once every parameter type has been registered, matched by reflection:
// app/dispatcher/default.go — the dispatcher asking for its dependencies
core.RequireFeatures(ctx, func(om outbound.Manager, router routing.Router,
pm policy.Manager, sm stats.Manager, dc dns.Client) error {
// …
return d.Init(config.(*Config), om, router, pm, sm)
}) If your config omits a feature, initInstanceWithConfig quietly installs a no-op stand-in: localdns.New() for DNS, routing.DefaultRouter{} (which rejects every route, so everything falls through to the default outbound), stats.NoopManager{}, and a default policy manager. That is why a config with no routing block still works.
An inbound is a listener glued to a protocol decoder
The inbound stage owns everything up to “I know the destination” — sockets, the proxy protocol, and the session metadata every later stage reads. For each configured port, AlwaysOnInboundHandler spawns a tcpWorker/udpWorker that listens and, per accepted connection, builds the session context (source address, inbound tag, sniffing config) and hands the raw connection to the protocol implementation:
// app/proxyman/inbound/worker.go — the moment stage 1 ends
if err := w.proxy.Process(ctx, net.Network_TCP, conn, w.dispatcher); err != nil {
errors.LogInfoInner(ctx, err, "connection ends")
} w.proxy is the protocol (VLESS, Trojan, SOCKS, …): it authenticates the client, decodes the requested destination, and calls dispatcher.Dispatch(ctx, dest) to get back a transport.Link — a pair of in-memory pipes it then copies bytes into.
The dispatcher routes after the first bytes arrive
Dispatch returns immediately and picks the route later, in a goroutine — the inbound proxy starts writing payload before an outbound even exists. The dispatcher creates two pipes, hands one end back, and meanwhile wraps the other end in a cachedReader so the content sniffer can peek at the first bytes (TLS SNI, HTTP Host) without consuming them. Sniffing has a hard budget: a 200 ms deadline and at most two inconclusive reads — an empty read, or bytes no sniffer can claim (common.ErrNoClue) — before it gives up with errSniffingTimeout; a sniffer that says “need more data” (ErrProtoNeedMoreData) may keep reading until the deadline. A sniffed domain can then overwrite the connection’s target — or, with routeOnly, only the value the router sees (ob.RouteTarget), while the outbound still dials the original IP.
The dispatcher is also where per-user accounting happens: if the inbound authenticated a user with an email — and that user’s policy level enables the stat — getLink wraps the pipe writers in SizeStatWriter counters named user>>>[email]>>>traffic>>>uplink / …>>>downlink and registers the source IP in the online-user map.
The router picks a tag, not a connection
Routing resolves to a string — router.PickRoute returns an outbound tag, and routedDispatch looks it up in the outbound manager. Miss the router entirely and traffic falls through to GetDefaultHandler(), which is simply the first outbound in your config. That is the whole contract between config and pipeline:
{
"routing": {
"rules": [{ "domain": ["geosite:category-ads"], "outboundTag": "block" }]
},
"outbounds": [{ "tag": "proxy", "protocol": "vless" }, { "tag": "block", "protocol": "blackhole" }]
} app/dispatcher/default.go features/outbound/outbound.go The outbound owns the wire — and is its own dialer
An outbound handler bundles a protocol encoder with a transport dialer — Handler.Dispatch resolves the target domain if a targetStrategy demands it, diverts traffic into the mux client manager if multiplexing is enabled (UDP goes to the XUDP manager when configured), then calls h.proxy.Process(ctx, link, h), passing itself as the internet.Dialer. When the protocol asks it to dial, Handler.Dial either opens a real transport connection (internet.Dial — TCP, WebSocket, XHTTP, REALITY…) or, if proxySettings.tag chains this outbound through another one, fabricates an in-memory connection out of two pipes and dispatches it into that handler — the whole five-stage pipeline recursing on stage four.