What is mDNS (and why would I want it)?

mDNS (multicast DNS, also known as Zeroconf or Bonjour) lets a machine claim a name.local hostname on the local network without any DNS server, static IP, or router config.
Instead of asking a central server “what’s the IP for this name?”, a device shouts the question onto the LAN over multicast, and whoever owns the name answers directly.

This is exactly what you want for a server running on your laptop, a Raspberry Pi, or some box on the office network: instead of telling people “go to http://192.168.1.42” you can tell them “go to http://javalin-demo.local” and it just works, as long as everyone is on the same network.

We’re going to wire JmDNS straight into Javalin’s lifecycle, so the hostname is published when the server starts and cleaned up when it stops.

Dependencies

First, we need a Maven project with Javalin and JmDNS: (→ Tutorial)

<dependencies>
    <dependency>
        <groupId>io.javalin</groupId>
        <artifactId>javalin-bundle</artifactId>
        <version>7.2.2</version>
    </dependency>
    <dependency>
        <groupId>org.jmdns</groupId>
        <artifactId>jmdns</artifactId>
        <version>3.6.3</version>
    </dependency>
</dependencies>

If you’re on Gradle instead, it’s the same two dependencies:

implementation("io.javalin:javalin-bundle:7.2.2")
implementation("org.jmdns:jmdns:3.6.3")

Publishing the hostname

The interesting part of JmDNS is the two-argument JmDNS.create(address, hostname) call:
the hostname is what becomes <hostname>.local, and the address tells JmDNS which network interface to multicast on. Once we have a JmDNS instance we register an _http._tcp service so HTTP clients (and service browsers) can discover us:

  • Java
  • Kotlin
private static JmDNS startMdns() {
    try {
        InetAddress address = selectAddress();
        JmDNS jmdns = JmDNS.create(address, HOSTNAME);
        log.info("mDNS hostname published: {}.local -> {}", HOSTNAME, address.getHostAddress());
        ServiceInfo service = ServiceInfo.create("_http._tcp.local.", HOSTNAME, PORT, "path=/");
        jmdns.registerService(service);
        log.info("mDNS service registered: {} (_http._tcp) on port {}", HOSTNAME, PORT);
        return jmdns;
    } catch (Exception e) {
        log.warn("Failed to start mDNS responder", e);
        return null;
    }
}
private fun startMdns(): JmDNS? = try {
    val address = selectAddress()
    val jmdns = JmDNS.create(address, HOSTNAME)
    log.info("mDNS hostname published: {}.local -> {}", HOSTNAME, address.hostAddress)
    val service = ServiceInfo.create("_http._tcp.local.", HOSTNAME, PORT, "path=/")
    jmdns.registerService(service)
    log.info("mDNS service registered: {} (_http._tcp) on port {}", HOSTNAME, PORT)
    jmdns
} catch (e: Exception) {
    log.warn("Failed to start mDNS responder", e)
    null
}

When we stop, we need to undo all of that, otherwise the name lingers on the network until the records time out. JmDNS makes this a two-liner:

  • Java
  • Kotlin
private static void stopMdns(JmDNS jmdns) {
    if (jmdns == null) return;
    try {
        jmdns.unregisterAllServices();
        jmdns.close();
    } catch (Exception e) {
        log.warn("Failed to stop mDNS responder", e);
    }
}
private fun stopMdns(jmdns: JmDNS?) {
    if (jmdns == null) return
    try {
        jmdns.unregisterAllServices()
        jmdns.close()
    } catch (e: Exception) {
        log.warn("Failed to stop mDNS responder", e)
    }
}

Wiring it to the Javalin lifecycle

Now that we have start/stop functions, we need to call them at the right time. Javalin has lifecycle events on config.events, so we hook serverStarted to publish and serverStopping to clean up.
We keep the JmDNS instance in a single field so both callbacks can reach it, and we bind on a fixed PORT so the advertised address stays predictable:

  • Java
  • Kotlin
private static final int PORT = 80;
private static JmDNS jmdns;

public static void main(String[] args) {
    Javalin app = Javalin.create(config -> {
        config.routes.get("/", ctx -> ctx.result("mDNS demo server is running. Served by " + HOSTNAME + ".local"));
        config.events.serverStarted(() -> jmdns = startMdns());
        config.events.serverStopping(() -> stopMdns(jmdns));
    });
    app.start(PORT);
}
private const val PORT = 80
private var jmdns: JmDNS? = null

fun main() {
    val app = Javalin.create { config ->
        config.routes.get("/") { it.result("mDNS demo server is running. Served by $HOSTNAME.local") }
        config.events.serverStarted { jmdns = startMdns() }
        config.events.serverStopping { stopMdns(jmdns) }
    }
    app.start(PORT)
}

Binding on a fixed port keeps the advertised address predictable, but there are still a few gotchas worth understanding.

Three gotchas

There are a handful of things that will silently break mDNS if you get them wrong. They’re all small, but they’re the difference between “works” and “works on my machine”.

1. Pick a non-loopback, site-local interface.
The convenient InetAddress.getLocalHost() often resolves to 127.0.0.1, and multicast on the loopback interface won’t reach anyone else on the LAN. So we walk the real interfaces and pick the first up, non-virtual IPv4 site-local address (your 192.168.x.x / 10.x.x.x address), falling back to getLocalHost() only if there’s nothing better:

  • Java
  • Kotlin
// Pick a real LAN interface; InetAddress.getLocalHost() can resolve to loopback, which breaks mDNS multicast.
private static InetAddress selectAddress() throws Exception {
    for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
        if (!ni.isUp() || ni.isLoopback() || ni.isVirtual() || ni.isPointToPoint()) continue;
        for (InetAddress addr : Collections.list(ni.getInetAddresses())) {
            if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) return addr;
        }
    }
    InetAddress fallback = InetAddress.getLocalHost();
    if (fallback.isLoopbackAddress()) {
        log.warn("No non-loopback site-local interface found; mDNS bound to {} and multicast may not work", fallback.getHostAddress());
    }
    return fallback;
}
// Pick a real LAN interface; InetAddress.getLocalHost() can resolve to loopback, which breaks mDNS multicast.
private fun selectAddress(): InetAddress {
    val siteLocal = NetworkInterface.getNetworkInterfaces().asSequence()
        .filter { it.isUp && !it.isLoopback && !it.isVirtual && !it.isPointToPoint }
        .flatMap { it.inetAddresses.asSequence() }
        .firstOrNull { it is Inet4Address && it.isSiteLocalAddress }
    if (siteLocal != null) return siteLocal
    val fallback = InetAddress.getLocalHost()
    if (fallback.isLoopbackAddress) {
        log.warn("No non-loopback site-local interface found; mDNS bound to {} and multicast may not work", fallback.hostAddress)
    }
    return fallback
}

2. Don’t let mDNS take down your server.
mDNS is a nice-to-have, not a hard dependency. If the responder can’t start (no network, a firewall, a permissions prompt), your HTTP server should still serve requests. That’s why startMdns catches everything, logs a warning, and returns null instead of throwing.

3. Fail fast on a blank hostname.
A blank hostname produces a broken, un-resolvable .local name. In this example the hostname is a constant, but if you make it configurable, validate it up front rather than publishing garbage onto the network.

Trying it out

Run the main method (the Java and Kotlin versions do the same thing). Because the server binds on port 80, browsers drop the :80, so you can just open http://javalin-demo.local with no port at all, and http://localhost works from this machine too.

Binding to port 80 needs elevated privileges, so run it with sudo (or from an IDE or terminal that already has the rights). The .local address works from this machine and from any other machine on the same network, so from a phone or another laptop on the same Wi-Fi it’s reachable with no IP needed.

Note: .local resolution needs a local mDNS resolver (built-in on macOS; Avahi on Linux; Bonjour on Windows).

When not to use this (and other caveats)

mDNS is wonderful for the same-LAN, zero-config case, and a poor fit for everything else:

  • It’s link-local only. It doesn’t cross subnets, doesn’t route over the internet, and won’t help you in a typical cloud deployment. If you need a name that resolves from anywhere, you want real DNS, not mDNS.
  • The client needs an mDNS resolver. macOS has one built in, Linux uses Avahi (usually preinstalled), and Windows needs Bonjour (it ships with iTunes, or as a standalone install).
  • macOS Local Network privacy can block it. On recent macOS, an app needs Local Network permission to multicast. If you see EHOSTUNREACH / “No route to host” when starting the responder, grant the app permission under System Settings → Privacy & Security → Local Network.
  • Names auto-rename on conflict. If javalin-demo.local is already taken, JmDNS will happily publish you as javalin-demo-2.local instead, so don’t assume the name you asked for is the name you got.

That’s it! A few dozen lines and your Javalin server has a friendly name on the local network.
The full runnable example (Java and Kotlin) is on GitHub.