There is an issue in Uptime Kuma with 106 comments. It was opened in August 2021, the project has since passed 93,000 stars, and the label is still bug.
Lots of 'timeout of 48000ms exceeded' during the day, but the site is still up?
I want to be careful here, because the obvious reading — "the monitor has a bug" — is the least interesting one, and probably wrong. What the thread actually documents, over five years, is something more uncomfortable: a monitor reporting a timeout cannot tell you whether the target failed or whether the probe failed.
Those are different events. They arrive as the same string.
The 48 seconds
First, where the number comes from, because it is not arbitrary. In server/model/monitor.js:
if (!this.timeout || this.timeout <= 0) {
this.timeout = this.interval * 0.8;
}
The default check interval is 60 seconds, so an unset timeout becomes 48 — 80% of the monitoring window. A single false timeout therefore consumes most of the time you thought you were monitoring.
There is a second timer too, ten seconds later:
signal: axiosAbortSignal((this.timeout + 10) * 1000),
So the request is killed by axios at 48s and by an abort signal at 58s. Whichever fires first names the error. timeout of 48000ms exceeded is axios's phrasing, which tells you which of the two clocks won.
Three ways a connection fails, and only one of them is noisy
I went looking for why a monitor would report 48 seconds when the site answers in under one. Before theorising, it helps to separate the ways an outbound connection can fail, because they behave nothing alike.
Measured on a Linux host, socket.connect() with a 5-second timeout:
| Target | Elapsed | Result |
|---|---|---|
127.0.0.1:9 — closed port |
0.00s |
ECONNREFUSED, immediately |
2001:db8::1 — no route at all (inside a default Docker container) |
0.00s |
ENETUNREACH, immediately |
198.51.100.1 — routed, packets dropped |
5.00s | timeout, no error |
2001:db8::1 — routed via default gateway, packets dropped |
5.00s | timeout, no error |
The distinction that matters is between the first two rows and the last two.
A refused connection and a missing route both return an error. The kernel knows immediately and says so. A monitor receiving either of those can report something specific, and a well-written one can say "this is my problem, not yours".
A blackholed path is different. There is a route. Packets leave. Nothing comes back, and nothing tells you they were dropped. TCP will retransmit into the void until something above it gives up. The only thing that ever happens is the clock running out.
So the message timeout of 48000ms exceeded is not evidence that the target was slow. It is evidence that nothing answered, which is a strictly weaker statement.
Why IPv6 is the usual suspect here
The thread has an unusually good summary comment from raabmar in June 2025, pulling together four years of reports. The recurring factor:
Cloudflare has definitely something to do with it. In my case, I could consistently reproduce the timeouts when Cloudflare is enabled. Pausing Cloudflare immediately solved it.
And a root cause offered by Buco7854 in June 2026:
The fix for me was to add this to the Uptime Kuma service in my docker compose:
sysctls: - net.ipv6.conf.all.disable_ipv6=1The theory is that Cloudflare proxied sites return both IPv4 (A) and IPv6 (AAAA) records. Docker containers don't have working IPv6 by default, so when a check landed on an IPv6 address it hung on the dead route until the timeout happened.
That is a plausible mechanism, and the plumbing supports it. A default Docker container has a loopback ::1 and nothing else, but it still resolves AAAA records happily — I confirmed this: getent ahostsv6 cloudflare.com inside an alpine container returns the real Cloudflare IPv6 addresses, while ip -6 route is empty.
DNS answers a question about the outside world. The routing table answers a question about this machine. They can disagree, and when they do, the application is the last to find out.
The part that makes it hard to fix
Here is where I would push back gently on the comment above, and where the thread's five years of frustration starts to make sense.
If the container truly has no IPv6 route, the connect fails in milliseconds with ENETUNREACH — I measured 0.00s. Node's autoSelectFamily (Happy Eyeballs) is enabled in the current source:
const httpsAgentOptions = {
maxCachedSessions: 0,
rejectUnauthorized: !this.getIgnoreTls(),
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
autoSelectFamily: true,
...(agentFamily ? { family: agentFamily } : {}),
};
and it would fall back to IPv4 within 250ms. A route that is missing fails fast; a fallback that exists gets used. That combination does not produce a 48-second wait.
A blackholed route does. The route exists, something upstream silently drops the packets, and there is no error to hand back — the connection attempt simply sits there until a clock above it expires.
So I built the three scenarios and timed them, using a local listener as the known-good address and a routed-but-dropped address as the blackhole:
| Addresses returned | Result |
|---|---|
| IPv6 blackholed + IPv4 working | connected in 259 ms |
| IPv4 working + IPv6 blackholed (other order) | connected in 2 ms |
| IPv6 blackholed only, no IPv4 | timeout at 48,048 ms |
That table is the interesting part, because it undercuts the simplest version of the theory.
When a target publishes both A and AAAA and your probe has a working IPv4 path, Happy Eyeballs handles it. It starts the IPv6 attempt, waits 250ms, starts IPv4 in parallel, and connects — 259ms, not 48 seconds. A dual-stack site behind Cloudflare is therefore not by itself enough to produce this bug.
For the 48-second case you need the probe to have no working alternative to fall back to: an IPv6-only target, or an IPv4 path that is also broken, or a monitor pinned to ipFamily: "ipv6" — which the code supports:
if (this.ipFamily === "ipv6") {
agentFamily = 6;
}
Which is why I suspect the thread contains more than one bug wearing the same error message. Some reports are a routing mismatch that fails fast and surfaces differently. Some are a genuinely blackholed path. Some are TLS: note maxCachedSessions: 0, which deliberately disables session reuse on every check, so each poll pays for a full handshake — and under a CDN that rate-limits or tarpits, a full handshake is exactly what gets held. And some are the site genuinely being slow.
Five years and 106 comments is what you get when one symptom string covers four causes. It is also why the thread cannot converge: each contributor is debugging a different bug and reasonably concluding that everyone else's fix did not work.
The general shape
Step back from Uptime Kuma and this is a pattern that shows up wherever infrastructure reports on itself:
A signal that reports the probe's view of the world, labelled as a fact about the target.
The monitor is doing exactly what it was asked: it opened a socket, waited, and the socket did not connect. It is not lying. It is answering a narrower question than the one you are reading the answer to.
I hit the same class of thing in a completely different tool last week. restic stores st_dev from stat() in repository metadata to detect hard links — but POSIX does not promise that device IDs are stable, and on virtual filesystems they are not. On ZFS snapshots, st_dev changes every time, so restic concludes every directory changed, re-writes all directory metadata on every backup, and reports a diff that contains nothing you did. That issue is open since 2020 with 111 comments. As kakra put it there:
inode and devid are not stable IDs, they should not go into the metadata storage of the repository — never ever.
Same shape. A tool computes something true about its own inputs, and the output is read as a statement about your data.
What actually helps
If you run a monitor, the useful question is not "did it time out" but "which of the three failure classes was that". A few things worth doing, in order of cost:
Distinguish the error classes in your alerting. A connection refused is a target that is up and not listening. ENETUNREACH is your probe. A timeout is unknown until you investigate further. If your monitor collapses all three into "down", it is making a claim it cannot support.
Check that your probe can reach the family it prefers. If the target publishes AAAA and your probe has no working IPv6 path, you have a coin-flip in your alerting that no amount of retry tuning will fix. Turning off IPv6 in the probe, or pinning it to IPv4, is a legitimate fix when you cannot control the path.
Watch the ratio, not the incident. One timeout among a hundred checks is noise. Thirty timeouts a day against five services, as one commenter reports, is a broken measurement. The number to alert on is the proportion, because a single incident has too many innocent explanations.
Time the failure modes against each other. If a target times out from your monitor but responds in 80ms from a shell on the same host, the monitor is the interesting variable, not the target.
What I am doing about it
I keep running into this from the other side, because I build a small server audit tool that answers "is anything about to break" and "did last night's backup actually run". Its whole premise is that the checks have to report something meaningful when they fail, not just exit non-zero.
The gap this thread exposes is one step earlier than anything the tool currently looks at: it audits the host, not the path from the host outward. So I am adding an eleventh check to it, called alert path verification. Given a hostname, it resolves A and AAAA, tests whether this machine can actually reach each family, compares the latency, and warns when the preferred family is blackholed rather than refused.
The point is not to detect outages. It is to tell you whether your monitoring could have detected one. A probe on a dead path is a green light nobody can see, which is the same failure as the backup that exited 0 for three weeks — just one layer out.
The Uptime Kuma issue is #275 and remains open. The restic one is #3041. Both are worth reading if you maintain either tool; neither is a case of the maintainers not caring, which is what makes them interesting.
Top comments (6)
the 259 ms vs 48,048 ms table is what makes this, dual stack behind cloudflare alone doesn't get you to 48 seconds, you need the probe to have nothing to fall back to. cheapest tie-breaker i know for the timeout case is a second probe from a different network, both time out it's probably the target, only one does it's the path. is the alert path check going to compare against anything outside the host, or stay purely local?
You have the mechanism exactly right, and the 259 ms is the tell. Behind Cloudflare with both families resolving, Happy Eyeballs does its job — I measured 259 ms to fall back when the AAAA pointed into a blackhole and an A record was there to catch it. Take the A record away, or pin the probe to ipFamily: ipv6, and that 250 ms timer has nothing to fall back to, so you sit there until the request timeout. That is where 48,048 ms comes from. Slow is not the failure; slow is the symptom of having no alternative.
On your question: local, as shipped. It resolves both families on the probe and connects from the probe, so it answers "is this host's path to this target broken" and nothing beyond that. Three outcomes rather than one string — ok, refused, timeout — because refused means the kernel got an answer (ECONNREFUSED, ENETUNREACH, EHOSTUNREACH) while timeout is the absence of one. Only timeout is ambiguous, so only timeout makes the probe a suspect. IPv6 timing out while IPv4 connects is the case I flag hardest, because the target demonstrably answers on one family and is silent on the other.
A second probe on a different egress is the right tie-breaker and it is the one thing a purely local check structurally cannot do. It is not in 1.0.2 and I would rather say that than ship a check that implies it. It needs a second host, a shared verdict rule, and an agreement policy — that is a different product shape, not another check. Next version.
One thing worth deciding before you build the two-probe rule: "both time out, so it is the target" only holds if the two probes fail independently. Two probes in the same AS, or resolving through the same resolver, fail together and agreement buys you nothing — it degrades to one probe with extra steps. The rule you actually want is two independent paths, and independence is the hard part, not the second probe.
This is the monitoring blind spot nobody likes to admit: a timeout string can't tell you whether the target died or the probe did. One thing that helped us in practice was treating the probe itself as a monitored entity — a second, independent check path (different host, different egress) so a down alert only fires when both agree. Did you consider surfacing the two failure modes as separate states instead of the same timeout string?
Agreed, and the state split you are describing is what shipped rather than what I am planning.
alertpath reports three outcomes per address family instead of collapsing them into one string: ok (connected), refused (the kernel got an answer — ECONNREFUSED, ENETUNREACH, EHOSTUNREACH), and timeout (nothing answered before the deadline). Only timeout is treated as ambiguous. A refused connection is evidence that the target side is alive and simply not listening on that port, so it reports as refused and never as down. That distinction alone removes a whole class of false pages.
The verdict then reads the pair rather than a single result. IPv6 timing out while IPv4 connects is the one I escalate to critical, because the target answers on one family and produces silence on the other — that is the path, not the target's health. Both families timing out is only a warning, for exactly your reason: one silent path is not two.
Two things I did while making the distinction real, because a state you cannot act on is just a longer string:
The check defaults to SKIP with the reason printed when no target is configured. A check that has never been pointed at anything must not be able to report ok. Silent success from an unconfigured check is the same false green as a timeout string that means two things.
Both families refusing is reported as refused, with a note that something is listening and chose to reject. At 3 a.m. that is a completely different call from a black hole, and collapsing them was the original mistake.
Treating the probe as a monitored entity is the part I have not earned yet. What exists now is a local probe that can tell you it is the suspect. It cannot tell you it is innocent — that needs the second independent path you are describing, and I would rather ship the honest half than imply the whole.
This is why an alert label should name the observed failure mode, not merely say down. I’d preserve the probe vantage point, DNS result, TCP outcome, and timeout phase in the event so operators can distinguish target failure from monitor-path failure before a retry storm starts.
"Down" is the one word that survives having no subject. Everything you listed is what makes the subject recoverable, and the ordering matters more than the field list — DNS, then TCP, then the SMTP or HTTP phase, because each one narrows what the next can mean. A DNS miss and a refused connection both end as "down" today and they have nothing in common operationally: one is a config error you can fix in a minute, the other is someone else's firewall.
The field I would put first is the vantage point, and it is the one most stacks omit. A probe reports from where it stands, so its verdict is about the path it took, not about the service. Recording where the probe ran turns "the site is down" into "my probe could not reach the site from here", which is a claim you can check rather than one you have to trust. Without it, two probes disagreeing looks like two bugs instead of one measurement taken from two places.
On timeout phase specifically: the distinction I would keep is refused versus silent. A refused connection is an answer — the kernel got a reply — so the target is alive and chose not to listen. Silence is the absence of an answer, and it is the only outcome where the monitor is a suspect. Collapsing those two into one timeout string is what produces a page at 3am that cannot be acted on.
The retry storm point is the part I had not thought about, and it may be the strongest argument for doing this properly: if the alert says only "down", the first thing everyone does is retry, and every retry is another data point nobody recorded.