Debugging Firefox's 8-second startup delay on KDE Wayland

Every time I opened Firefox, there was an awkward 8-to-10-second wait before anything appeared on screen. No splash, no window, just a spinning cursor and blind faith that something was happening. It was consistent, reproducible with a brand-new profile, and annoying enough to be worth investigating properly.

This is the story of that investigation.

First steps: the obvious culprits

My first instinct was to look at things known to cause slow GTK application startups on KDE: XDG Desktop Portals, the accessibility bus (AT-SPI), and GDK’s Wayland backend initialization. I tried disabling each in turn with environment variables — GTK_USE_PORTAL=0, NO_AT_BRIDGE=1, GTK_A11Y=none, even GDK_BACKEND=x11 to force X11 mode. None of them made any difference. Firefox about:config’s own widget.use-xdg-desktop-portal toggle also had no effect.

Getting serious: perf and strace

I ran perf record during startup and spotted g_dbus_proxy_new_for_bus_sync — a synchronous D-Bus proxy creation — in the call stack. That was a tempting lead. But capturing the session bus with busctl monitor showed only a brief flurry of dconf subscriptions right at startup, then 8 seconds of complete silence. Firefox wasn’t blocking on D-Bus.

Time to use strace properly. I captured a trace with network and IPC syscalls:

strace -f -s 256 -e trace=network,ipc -o /tmp/ff.log firefox --new-instance --no-remote -P Test

This showed the crash reporter thread blocking on poll([{fd=11}], 1, -1) — an internal Firefox socketpair — but I couldn’t see why the main thread was so slow to send the signal that would unblock it. The 8-second gap was invisible because poll() wasn’t in my filter. I’d captured ipc (which includes socketpairs) but missed the polling calls entirely.

I added poll to the filter and re-ran. This time I could measure it: the crash reporter’s poll took exactly 8.004 seconds. But the main thread still appeared to be doing fast individual operations — ELF reads, ICE authentication, config file reads — each completing in microseconds.

The second mistake was subtler: my filter used trace=poll, which on modern Linux captures the poll syscall, but not ppoll — they’re separate syscalls. The main thread was using ppoll.

Finding the real block

For the third strace run I switched to capturing blocking-oriented syscalls with absolute timestamps:

strace -f -tt -e trace=poll,ppoll,epoll_wait,futex,select -e signal=none -o /tmp/ff4.log \
  firefox --new-instance --no-remote -P Test

This immediately showed what had been invisible before. The main thread blocked in:

ppoll([{fd=20, events=POLLIN}], 1, {tv_sec=119, tv_nsec=999992000}, NULL, 8)

…and sat there for 3.95 seconds before returning. Then, 4 seconds later, after a stretch with no visible blocking calls, the crash reporter poll finally unblocked. Two separate ~4-second delays, adding up to 8 seconds.

Identifying the file descriptor

ppoll told me the duration but not the destination. I needed to know what fd=20 was. A quick strace pass capturing only connect calls revealed it:

strace -f -tt -e trace=connect -e signal=none -o /tmp/ff5.log \
  firefox --new-instance --no-remote -P Test
connect(20, {sa_family=AF_UNIX, sun_path="/run/systemd/resolve/io.systemd.Resolve"}, 42) = 0
# ...3.8 seconds later...
connect(20, {sa_family=AF_UNIX, sun_path="/tmp/.ICE-unix/1242"}, 21) = 0

fd=20 was used first for a systemd-resolved query, then immediately reused for the ICE socket connection. Something was being looked up in systemd-resolved and it was taking nearly 4 seconds to respond.

The actual query

systemd-resolved speaks the varlink protocol. I needed to capture sendto/recvfrom to see what was being asked:

strace -f -tt -s 256 -e trace=connect,sendto,recvfrom -e signal=none -o /tmp/ff7.log \
  firefox --new-instance --no-remote -P Test
sendto(20, "{\"method\":\"io.systemd.Resolve.ResolveHostname\",\"parameters\":{\"name\":\"archbox\",\"family\":2,\"flags\":0,\"ifindex\":0}}\0", ...)
recvfrom(20, 0x..., 131072, MSG_DONTWAIT, NULL, NULL) = -1 EAGAIN
# ... 3.8 seconds of retries ...
recvfrom(20, "{\"error\":\"io.systemd.Resolve.DNSError\",\"parameters\":{\"rcode\":3}}\0", ...)

Firefox was asking systemd-resolved to resolve archbox — the machine’s short hostname — and getting NXDOMAIN back after 3.8 seconds. This happened twice in sequence, for a total of ~8 seconds.

Why does Firefox look up its own hostname?

Firefox uses libICE (X Inter-Client Exchange) to register with the KDE session manager (ksmserver). This is the legacy X11 session management protocol that allows a session manager to save and restore application state across logins. When Firefox connects to ksmserver, ICElib authenticates using MIT-MAGIC-COOKIE-1 tokens stored in ~/.ICEauthority. To look up the right token, it calls getaddrinfo("archbox") — the short hostname from gethostname(). It does this twice: once for the ICE protocol layer, once for the XSMP sub-protocol.

Why was the lookup so slow?

My machine’s short hostname is archbox, and its FQDN is archbox.home.lab. The /etc/hosts file already had:

127.0.0.1 archbox.home.lab

But not the bare archbox. When systemd-resolved was asked to resolve archbox, it had no local match and fell back to LLMNR (Link-Local Multicast Name Resolution), broadcasting queries on the local network and waiting for a response that never came — retrying several times before finally giving up with NXDOMAIN.

Finally, the fix

One line in /etc/hosts:

127.0.0.1 archbox

systemd-resolved checks /etc/hosts before attempting network queries. With that entry in place, it returns immediately and Firefox starts in under a second.

This investigation was done with help from Claude. I went in skeptical that an AI would be genuinely useful for this kind of low-level systems debugging — the sort of work that requires iterating on hunches, reading raw strace output, and knowing which tool to reach for next. It turned out to be a good surprise. It didn’t just suggest things to try; it reasoned through the evidence with me and caught things I would have taken longer to notice on my own.

Fortunately the solution was simple, but I just wish this was easier to debug to be honest.