# ERR_CONNECTION_TIMED_OUT: Trace TCP Connection Failures

> Fix ERR_CONNECTION_TIMED_OUT by tracing SYN packets, checking firewalls and stale DNS, testing IPv6, and separating backlog pressure from MTU stalls.

Source: https://howhttpworks.com/debug/err-connection-timed-out
Last reviewed: 2026-10-05

Error messages this page covers:
- `ERR_CONNECTION_TIMED_OUT`
- `curl: (28) Connection timed out after 5000 milliseconds`
- `curl: (28) Failed to connect to example.com port 443 after 5000 ms: Timeout was reached`
- `ETIMEDOUT`
- `connect ETIMEDOUT 203.0.113.10:443`
- `TimeoutError: timed out`
- `upstream timed out (110: Connection timed out) while connecting to upstream`

> **TL;DR:** Your client sent a connection request and never heard back. Run `nc -vz -w 5 example.com 443` and `curl --noproxy '*' -v --connect-timeout 5 --max-time 15 https://example.com/`, and note the address after `Trying` and whether curl prints `Connected to`. If it never connects, capture SYN packets at both ends and check firewalls, DNS records and IPv6. If TCP connects and then TLS or the response stalls, you have a different problem, often path MTU.

## What it means

Chromium defines [`ERR_CONNECTION_TIMED_OUT`](https://github.com/chromium/chromium/blob/main/net/base/net_error_list.h) as a connection attempt that timed out. A TCP connection opens with SYN, SYN-ACK, ACK ([RFC 9293](https://www.rfc-editor.org/rfc/rfc9293#section-3.10.7)). If the client keeps resending SYN and nothing comes back, packets are being silently dropped, the destination isn't responding, or replies can't find their way back. Which firewall or host is to blame is something you have to find out with captures.

It helps to separate three failures that look similar from the browser:

- **Timeout:** the connection didn't open before the deadline. A firewall rule that silently `DROP`s packets produces exactly this.
- **[Refused](https://howhttpworks.com/debug/err-connection-refused):** something actively said no, usually an RST because nothing listens on the port. A firewall set to reject does this too.
- **[Reset](https://howhttpworks.com/debug/err-connection-reset):** an RST killed the connection, often after it opened, during TLS or mid-response.

When a direct TCP connection fails, there's no HTTP status at all, because HTTP never started. If a proxy is in the middle and its own upstream connection times out, you get an HTTP [504](https://howhttpworks.com/debug/nginx-504-gateway-timeout) instead.

### Exact client and server messages

The messages below are examples based on the linked source code, not captured from real timeout runs. **curl 8.7.1** has separate code paths for an [overall timeout](https://github.com/curl/curl/blob/curl-8_7_1/lib/multi.c) and a [failed connection](https://github.com/curl/curl/blob/curl-8_7_1/lib/connect.c), plus the [timeout description](https://github.com/curl/curl/blob/curl-8_7_1/lib/strerror.c):

```text
curl: (28) Connection timed out after 5000 milliseconds
curl: (28) Failed to connect to example.com port 443 after 5000 ms: Timeout was reached
```

The milliseconds depend on your deadline and how long curl actually waited. The second message appears when a single connection attempt hits curl's timeout; an OS-level `connect()` timeout may be worded differently. Newer versions change the wording too: **curl 8.22.0** [prints the destination as `example.com:443`](https://github.com/curl/curl/blob/curl-8_22_0/lib/cf-ip-happy.c). Include `curl --version` in any bug report.

Node raises [`ETIMEDOUT`](https://nodejs.org/api/errors.html#common-system-errors) when a connect or send got no response in time. Its [connect error formatter](https://github.com/nodejs/node/blob/main/lib/internal/errors.js) produces messages like this one. Check `err.syscall` as well as `err.code` to see which operation timed out:

```text
connect ETIMEDOUT 203.0.113.10:443
```

Python 3.14's [socket implementation](https://github.com/python/cpython/blob/3.14/Modules/socketmodule.c) raises `TimeoutError: timed out`. The message is the same whether connect, read or write expired, so check where in your code it was thrown.

In nginx's error log on Linux, this line means nginx itself couldn't connect **to the upstream** server:

```text
upstream timed out (110: Connection timed out) while connecting to upstream
```

nginx's [upstream code](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) logs both the timeout and what it was doing at the time. The `110: Connection timed out` part comes from Linux's [errno definitions](https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/errno.h) and the [glibc message](https://github.com/bminor/glibc/blob/master/sysdeps/gnu/errlist.h); other platforms use different numbers. If your log says `while reading response header from upstream` instead, the connection worked and the upstream was slow to answer. That's covered in the [nginx 504 guide](https://howhttpworks.com/debug/nginx-504-gateway-timeout).

## Establish which phase failed

From the machine that sees the error, with your real hostname in place of `example.com`:

```bash
nc -vz -w 5 example.com 443
curl --noproxy '*' -v --connect-timeout 5 --max-time 15 https://example.com/
```

`nc` with [OpenBSD-style options](https://man.openbsd.org/nc) tests the TCP connection alone, with no HTTP involved. Flags vary between nc implementations, so check `nc -h`. [`--noproxy '*'`](https://curl.se/docs/manpage.html) makes curl ignore proxy environment variables so you test the direct route; if you normally go through a proxy, test that path as well.

Note that curl's `--connect-timeout` **covers more than the SYN**: it includes DNS and the TCP, TLS or QUIC handshakes. `--max-time` caps the whole transfer. Then read the verbose output to see where it stopped:

- No address, or name resolution failed: start with [DNS debugging](https://howhttpworks.com/debug/dns-probe-finished-nxdomain).
- `Trying` and then nothing: TCP never connected. Work through the checks below.
- `Connected to`, then the TLS handshake hangs: TCP is fine. Look at TLS and at packet-size problems.
- TLS completes and the request goes out, but no response comes back: look at the application, any proxy, and the data path.

These commands test HTTPS over TCP. If the browser is using HTTP/3 over QUIC, they won't reproduce its path; see [HTTP/3 and QUIC](https://howhttpworks.com/guides/http3-and-quic) for that case.

## Fix it, in diagnostic order

### 1. A firewall drops the SYN or its reply

Run a capture on the client and on the server at the same time, then make one connection attempt. Swap in your real destination address:

```bash
# Linux; on macOS replace any with the active interface, such as en0
sudo tcpdump -ni any 'host 203.0.113.10 and tcp port 443'
# Narrow IPv4 filter: includes SYN and SYN-ACK, but excludes RST and data
sudo tcpdump -ni any 'tcp[tcpflags] & tcp-syn != 0'
```

The [libpcap flag filter](https://github.com/the-tcpdump-group/libpcap/blob/master/pcap-filter.manmisc.in) matches any packet with SYN set, which includes SYN-ACK. It only works for IPv4, though, and hides RST, ACK and data. For IPv6, or to see everything, use the host/port filter.

- Client sends SYN, server never sees it: check the network path, cloud firewall, security group and the destination IP.
- Server sees the SYN but never answers: check the host firewall, whether something is listening, and queue pressure.
- Server sends SYN-ACK, client never gets it: check the return route and outbound filtering.

On a Linux server:

```bash
sudo ss -ltnp 'sport = :443'
sudo nft list ruleset
ip route get 198.51.100.24
```

Use your client's real address there too. Then check the cloud firewall attached to that exact NIC or instance, because no local allow rule can override a drop that happens before the packet reaches the host. On AWS, [security groups are stateful](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html) but [network ACLs are stateless](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html). So a subnet ACL has to allow both the inbound service port and the outbound replies to the client's ephemeral port. AWS's [ACL examples](https://docs.aws.amazon.com/vpc/latest/userguide/custom-network-acl.html) explain why that port range depends on the client OS.

To allow a known client into a security group (the group ID and address are placeholders):

```bash
aws ec2 authorize-security-group-ingress \
  --group-id sg-0123456789abcdef0 \
  --protocol tcp --port 443 --cidr 198.51.100.24/32
```

Check first that an equivalent rule isn't already there. The [AWS CLI](https://docs.aws.amazon.com/cli/latest/reference/ec2/authorize-security-group-ingress.html) command only adds the ingress rule; the subnet ACL and route table are separate.

With nftables, a narrowly scoped rule in an existing `inet filter input` chain looks like this:

```bash
sudo nft insert rule inet filter input ip saddr 198.51.100.24 tcp dport 443 accept
```

Adjust the table, chain and source address to your setup, and save the rule through whatever manages your firewall config so it survives a reboot. [`insert`](https://netfilter.org/projects/nftables/manpage.html) puts it ahead of the chain's existing rules, but other chains and upstream firewalls get their say too. Resist flushing the whole ruleset to test one port; you'll open everything else as well.

### 2. DNS points at the wrong or retired machine

```bash
dig example.com. A
dig example.com. AAAA
dig @1.1.1.1 example.com. A
# Replace the address with the known-good endpoint
curl --noproxy '*' -v --connect-timeout 5 --max-time 15 \
  --resolve example.com:443:203.0.113.10 https://example.com/
```

Compare every returned address with your current load balancer or server inventory. [`--resolve`](https://curl.se/docs/manpage.html#--resolve) points the hostname at an address you choose while keeping the hostname for TLS and HTTP. That's a better test than `https://IP/`, which breaks certificate name checks and virtual-host routing.

If the override works but normal DNS hands out a retired address, fix the A/AAAA record and clear out stale hosts-file entries. Cached answers linger, so wait for them to expire before you shut down the old endpoint. If the hostname has several addresses, test each one. A single dead backend makes the failure look random, because it depends on which address the client picks.

### 3. IPv6 resolves but the path is broken

```bash
curl --noproxy '*' -4 -v --connect-timeout 5 --max-time 15 https://example.com/
curl --noproxy '*' -6 -v --connect-timeout 5 --max-time 15 https://example.com/
dig example.com. AAAA
# Linux server
sudo ss -ltnp 'sport = :443'
ip -6 route
```

If `-4` works and `-6` times out, the problem is specific to IPv6. Check that the AAAA record points at the right endpoint, that the server listens on IPv6, and that IPv6 routing and firewalls allow the connection and its replies. Then fix those, or remove the wrong AAAA record. Forcing `-4` is a way to diagnose, not a reason to leave IPv6 off.

### 4. The server listens, but its queues fill under load

Linux keeps two queues: half-open handshakes, and finished connections waiting for the application to `accept()` them. The [kernel documentation](https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html) covers `tcp_max_syn_backlog` and the `somaxconn` cap on the listen backlog. When the accept queue fills up, the kernel drops new SYNs, and clients time out even though `ss` shows the port listening.

```bash
sudo ss -ltnp 'sport = :443'
ss -nt state syn-recv 'sport = :443'
nstat -az TcpExtListenOverflows TcpExtListenDrops
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
```

Run these again while failures are happening. If [ListenOverflows and ListenDrops](https://docs.kernel.org/networking/snmp_counter.html) are both climbing, the queue is overflowing (ListenDrops alone can rise for other reasons). The real cause is usually a blocked accept loop, saturated workers or too little capacity. A bigger queue helps absorb a burst, but the app still drains connections at the same speed.

If you've measured a burst that really needs more queue space, here's an **example Linux/nginx configuration** asking for a backlog of 4096:

```nginx
# Modify the existing listen directive; retain its other parameters
listen 443 ssl backlog=4096;
```

```text
# /etc/sysctl.d/90-web-backlog.conf
net.core.somaxconn = 4096
```

```bash
sudo sysctl -p /etc/sysctl.d/90-web-backlog.conf
sudo nginx -t && sudo nginx -s reload
```

[`backlog`](https://nginx.org/en/docs/http/ngx_http_core_module.html#listen) sets what nginx requests from `listen()`, and the kernel's `somaxconn` caps it. 4096 is an example, not a recommended value. Afterwards, confirm the config actually loaded and watch the counters.

## TCP connected, then larger packets disappeared

In a classic [PMTUD black hole](https://www.rfc-editor.org/rfc/rfc2923#section-2.1), the small handshake packets get through, but larger packets are too big for some link on the path. The router that drops them is supposed to send an ICMP message back so the sender shrinks its packets. When a firewall blocks that ICMP, the sender never finds out. TCP connects fine, then TLS or the response stalls. That's a different problem from SYNs with no reply.

```bash
# Linux: inspect established TCP details, including path MTU
ss -tin 'dport = :443'
sudo tcpdump -ni any '(host 203.0.113.10 and tcp port 443) or icmp or icmp6'
```

In the capture, look for large data segments being retransmitted over and over with no ICMP feedback, rather than handshake retries. The fix is to let IPv4 “fragmentation needed” and IPv6 “Packet Too Big” messages through your firewalls, and to set the right MTU on tunnels and interfaces for the path you measured. Setting a guessed MTU on every interface tends to create new problems.

As a test on the Linux machine that's sending the large packets:

```bash
sudo sysctl -w net.ipv4.tcp_mtu_probing=1
```

This [kernel setting](https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html) turns on TCP MTU probing when the kernel detects an ICMP black hole. It only helps on the side sending the large packets; changing it on the client won't fix the server's sends. If the symptom goes away, still fix the blocked ICMP or the tunnel MTU. Probing is a workaround.

## Trace the path without overreading it

A successful ping doesn't tell you much here. Probe with TCP to the actual port instead:

```bash
# Linux traceroute's TCP mode
sudo traceroute -T -p 443 example.com
# Where tcptraceroute is installed
sudo tcptraceroute example.com 443
sudo mtr -T -P 443 -r -c 20 example.com
```

[`traceroute -T`](https://man7.org/linux/man-pages/man8/traceroute.8.html), [`tcptraceroute`](https://github.com/mct/tcptraceroute/blob/master/tcptraceroute.1) and [`mtr -T -P`](https://github.com/traviscross/mtr/blob/master/man/mtr.8.in) all send TCP SYNs to the port you choose. Flags differ between platforms, so check your installed tool's manual. Read the results carefully: a hop that shows `*` hasn't necessarily dropped your traffic, since many routers rate-limit or ignore probe replies while forwarding packets normally. What counts is whether the final destination answers, checked against your client and server captures.

## Related

- [ERR_CONNECTION_REFUSED](https://howhttpworks.com/debug/err-connection-refused) — identify active rejection and missing listeners.
- [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset) — diagnose a connection aborted with RST.
- [DNS_PROBE_FINISHED_NXDOMAIN](https://howhttpworks.com/debug/dns-probe-finished-nxdomain) — separate DNS failure from connection failure.
- [nginx 504 Gateway Timeout](https://howhttpworks.com/debug/nginx-504-gateway-timeout) — inspect a proxy's upstream timeout phase.
- [HTTP/3 and QUIC](https://howhttpworks.com/guides/http3-and-quic) — a browser may use a UDP transport instead of this TCP path.
