# How Sniffnet Performs Asynchronous IP Address Resolution Without Blocking the UI

> Discover how Sniffnet achieves asynchronous IP address resolution without UI blocking. Learn about its worker thread and Rust channel implementation for a seamless user experience.

- Repository: [Giuliano Bellini/sniffnet](https://github.com/GyulyVGC/sniffnet)
- Tags: internals
- Published: 2026-04-28

---

**Sniffnet isolates reverse‑DNS lookups in a dedicated worker thread and communicates results through Rust channels, allowing the UI thread to remain responsive while resolving IP addresses.**

Sniffnet is a Rust-based network traffic analyzer that must resolve IP addresses to human-readable hostnames without freezing its graphical interface. The application achieves this by offloading potentially slow DNS queries to background threads and coordinating results through Rust's channel-based concurrency. This architecture ensures that the main packet processing loop and the UI event loop never block on network I/O.

## The Challenge: Synchronous DNS Blocks the Event Loop

Reverse-DNS lookups using standard library functions like `dns_lookup::lookup_addr` perform synchronous network I/O that can take hundreds of milliseconds or longer. When performed on the main thread, these calls freeze the user interface, creating jank or unresponsive behavior during live traffic capture.

## Thread Architecture and Channel Communication

Sniffnet solves this by separating concerns across multiple threads and using Rust's multi-producer, single-consumer (`mpsc`) channels for safe message passing.

### The Capture Thread (`parse_packets`)

The main packet processing logic lives in [`src/networking/parse_packets.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/networking/parse_packets.rs) inside the `parse_packets` function. This runs in its own OS thread and handles the continuous stream of captured network packets.

### The Reverse DNS Worker Thread

Inside `parse_packets`, Sniffnet spawns a second dedicated thread named `thread_reverse_dns_lookups` using `std::thread::Builder`. This thread exists solely to perform blocking DNS queries.

```rust
thread::Builder::new()
    .name("thread_reverse_dns_lookups".to_string())
    .spawn(move || {
        reverse_dns_lookups(&lookup_request_rx, &lookup_result_tx, &mmdb_readers);
    });

```

## Channel-Based Coordination

The system uses two distinct channels to coordinate work between the parser and the DNS worker:

- **`lookup_request_tx / lookup_request_rx`**: Carries lookup requests from the parser to the worker. Each message contains a tuple of `(AddressPortPair, TrafficDirection, Vec<Address>)`.
- **`lookup_result_tx / lookup_result_rx`**: Returns resolved host information from the worker back to the parser. Messages are of type `HostMessage`.

### Requesting a DNS Lookup

When `parse_packets` encounters an IP address requiring resolution, it sends a request to the worker thread without blocking:

```rust
if !r_dns_already_resolved && !r_dns_waiting_resolution {
    // Track that we are waiting for this address
    resolutions_state.addresses_waiting_resolution.insert(
        address_to_lookup,
        DataInfo::new_with_first_packet(exchanged_bytes, traffic_direction),
    );

    // Send request to DNS worker thread
    let _ = resolutions_state.lookup_request_tx.send((
        key,                         // address/port pair from the packet
        traffic_direction,           // inbound or outbound
        cs.get_addresses().clone(),  // local interface addresses
    ));
}

```

### The DNS Worker Implementation

The worker thread runs an infinite loop that blocks only inside `lookup_addr`, then immediately sends results back:

```rust
fn reverse_dns_lookups(
    lookup_request_rx: &Receiver<(AddressPortPair, TrafficDirection, Vec<Address>)>,
    lookup_result_tx: &Sender<HostMessage>,
    mmdb_readers: &Option<MmdbReaders>
) {
    while let Ok((key, traffic_direction, interface_addresses)) = lookup_request_rx.recv() {
        // This is the only blocking call
        let lookup_result = dns_lookup::lookup_addr(&address_to_lookup);
        
        let msg_data = HostMessage {
            host: lookup_result.ok().map(|s| Host::from((s, key, &traffic_direction))),
            // ... additional fields
        };
        
        let _ = lookup_result_tx.send(msg_data);
    }
}

```

## Non-Blocking Result Collection

While parsing packets, the main thread periodically checks for completed lookups without waiting. The `AddressesResolutionState` struct manages this state, and its `new_hosts_to_send` method uses `try_recv` to poll the result channel:

```rust
// Inside AddressesResolutionState::new_hosts_to_send
while let Ok(mut host_msg) = self.lookup_result_rx.try_recv() {
    // Process completed lookup
    // ...
}

```

If no results are ready, `try_recv` returns immediately with an error (usually `Empty`), and the parser continues processing the next packet. This ensures the capture loop never stalls on DNS resolution.

## UI Integration via Async Channels

Resolved hosts eventually reach the GUI through a separate async channel. The backend packages updates into `BackendTrafficMessage` variants and sends them via `async_channel::Sender` (`tx`):

```rust
// Inside maybe_send_tick_run_live or maybe_send_tick_run_offline
let _ = tx.send_blocking(BackendTrafficMessage::TickRun(
    cap_id,
    msg,
    host_msg,
    no_more_packets
));

```

### GUI Subscription and Handling

In [`src/gui/sniffer.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/gui/sniffer.rs), the GUI subscribes to this channel using `Task::run`, converting backend messages into UI `Message` types that process on the main UI thread:

```rust
Task::run(rx, |backend_msg| match backend_msg {
    BackendTrafficMessage::TickRun(cap_id, msg, host_msg, no_more_packets) => {
        Message::TickRun(cap_id, msg, host_msg, no_more_packets)
    }
    BackendTrafficMessage::PendingHosts(cap_id, host_msg) => {
        Message::PendingHosts(cap_id, host_msg)
    }
    BackendTrafficMessage::OfflineGap(cap_id, gap) => {
        Message::OfflineGap(cap_id, gap)
    }
});

```

Because the DNS work happens on the separate `thread_reverse_dns_lookups` thread, the UI thread never blocks on network I/O, maintaining 60 FPS responsiveness even during heavy traffic analysis.

## Key Source Files Reference

| File | Role |
|------|------|
| [`src/networking/parse_packets.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/networking/parse_packets.rs) | Main packet-parsing loop, spawns DNS worker, coordinates channels |
| [`src/gui/sniffer.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/gui/sniffer.rs) | GUI core; receives `BackendTrafficMessage` via async channel |
| [`src/networking/types/host.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/networking/types/host.rs) | Definition of `Host` and `HostMessage` for transporting resolved data |
| [`src/networking/types/ip_collection.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/networking/types/ip_collection.rs) | Holds `AddressesResolutionState` tracking pending lookups |

## Summary

- **Worker thread isolation**: Sniffnet spawns a dedicated thread `thread_reverse_dns_lookups` in [`src/networking/parse_packets.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/networking/parse_packets.rs) to perform blocking `dns_lookup::lookup_addr` calls.
- **Dual-channel architecture**: Request channels carry `(AddressPortPair, TrafficDirection, Vec<Address>)` tuples to the worker, while result channels return `HostMessage` structs.
- **Non-blocking polling**: The parser uses `try_recv` in `AddressesResolutionState::new_hosts_to_send` to collect results without waiting.
- **Async UI updates**: Resolved data flows through `async_channel` to the GUI in [`src/gui/sniffer.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/gui/sniffer.rs), processed via `Task::run` to keep the main thread responsive.

## Frequently Asked Questions

### Why does Sniffnet use OS threads instead of async/await for DNS resolution?

Sniffnet uses `std::thread` instead of async tasks because `dns_lookup::lookup_addr` performs synchronous system calls that block the entire thread. By isolating these calls in a dedicated OS thread, the application can perform true parallelism while keeping the async runtime (used by the Iced GUI framework) unblocked for UI updates. Rust's channels provide the necessary synchronization between these thread domains.

### What happens if a reverse-DNS lookup times out or fails?

When `lookup_addr` fails or times out (typically after OS-defined timeouts), the worker thread captures the `Result` and wraps it in a `HostMessage`. The message is sent back through `lookup_result_tx` regardless of success or failure. The UI layer handles missing hostnames gracefully, displaying the raw IP address when resolution fails, ensuring no indefinite blocking occurs even with unresponsive DNS servers.

### How does the UI know when new host information is available?

The UI subscribes to an `async_channel::Receiver` created during application startup. When the DNS worker finishes lookups and the parser aggregates results, the backend sends `BackendTrafficMessage::TickRun` or `BackendTrafficMessage::PendingHosts` through the channel. The `Task::run` mechanism in [`src/gui/sniffer.rs`](https://github.com/GyulyVGC/sniffnet/blob/main/src/gui/sniffer.rs) converts these backend messages into UI `Message` variants, which the Iced framework processes on the next frame update.

### Can this pattern be applied to other Rust GUI applications?

Yes. The pattern of spawning worker threads for blocking I/O and communicating results via channels is framework-agnostic and works with Iced, egui, Tauri, or other Rust GUI toolkits. The key is ensuring the main event loop uses `try_recv` (for sync channels) or async receivers (for async channels) to pull data without blocking, allowing the UI to maintain responsiveness while background work completes.