How HTTP Works

Debug guide · you're seeing

  • ERR_EMPTY_RESPONSE
  • didn’t send any data.
  • curl: (52) Empty reply from server
  • Error: socket hang up
  • ECONNRESET
  • Remote end closed connection without response
  • upstream prematurely closed connection while reading response header from upstream

ERR_EMPTY_RESPONSE: Find Who Closed the Connection

Debug ERR_EMPTY_RESPONSE and curl empty replies by checking app crashes, wrong ports, nginx 444, Docker listeners, keep-alive races and request framing.

Reviewed 8 min readintermediate24 sourcesTry itMarkdown
On this page

TL;DR: Run curl -v --http1.1 https://example.com/failing-path and look for a connection followed by a sent request (>) but no response status line (< HTTP/...). Repeat directly against the app from the proxy host. If only the public route fails, inspect the proxy’s routing and drop rules; if the app also closes without replying, correlate its logs and restart/OOM evidence with the request time.

What it means

Chromium defines EMPTY_RESPONSE as a connection closed without sending data. Its English error page says the hostname didn’t send any data. curl’s HTTP completion check reports Empty reply from server when no response headers or body arrived.

For a direct HTTP/1.1 request, the server accepted the TCP connection and closed it without sending HTTP response bytes. With HTTPS, TLS handshake bytes may already have passed; “no data” here means no HTTP response, not literally no packets. If a proxy accepted the connection, the message does not prove the origin application received the request.

Illustrative curl output, verified against its source:

curl: (52) Empty reply from server

This is not an HTTP status code. Under HTTP/1.1 framing, even a response without a body still has a status line and header section. A 204 No Content or 200 OK with Content-Length: 0 is a valid response, not this error. A refused TCP connection belongs to ERR_CONNECTION_REFUSED; a reset can instead produce ERR_CONNECTION_RESET or a curl receive error.

Node’s HTTP client can emit Error: socket hang up with code ECONNRESET on premature closure. Those strings are broader than an empty reply and can also follow a locally aborted request. Python’s http.client.RemoteDisconnected corresponds to reading no response data; its source message is Remote end closed connection without response.

Establish where the reply disappears

Use the actual failing path, method and Host. The following examples use GET; substitute the original request when it is safe to repeat:

curl -v --http1.1 --max-time 15 https://example.com/failing-path -o /dev/null
# From the proxy host, if the app is an HTTP listener on port 3000:
curl --noproxy '*' -v --http1.1 --max-time 15 \
  -H 'Host: example.com' http://127.0.0.1:3000/failing-path -o /dev/null

--http1.1 removes HTTP/2 negotiation from this comparison. If the forced HTTP/1.1 request works while the original protocol fails, investigate that protocol path rather than declaring the entire endpoint healthy. Verbose output can contain authorization headers and cookies; redact it before sharing.

For a cleartext listener, nc lets you type a minimal request:

nc -v example.com 80

Type these lines and press Enter twice after the last header:

GET / HTTP/1.1
Host: example.com
Connection: close

Interactive line endings depend on the terminal. For an exact request with CRLF terminators and a final blank line, use:

printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
  | nc -w 5 example.com 80

nc connecting proves only that something accepted TCP. Use curl for HTTPS; plain nc does not perform a TLS handshake. A timeout waiting for a reply is also different from immediate closure.

Find and fix the cause

Check app health and routing first, then deliberate drops and reuse.

1. The app crashes or is killed before writing

If the app accepts a request and exits before writing headers, the client may see an empty reply or a reset. Look for an exception, worker exit or memory kill at the exact request time:

docker logs --timestamps --since 10m web
docker inspect --format \
  'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} RestartCount={{.RestartCount}}' web
# Linux host or VM:
sudo journalctl -k --since '10 minutes ago' | grep -iE 'oom|out of memory|killed process'
sudo journalctl -u my-app --since '10 minutes ago'

Docker documents the kernel OOM killer: it can kill container processes under memory pressure. Exit code 137 alone is not proof of OOM; inspect the OOM state and host evidence. A restarted container’s current state is not a complete incident history. Run the kernel-log check on the Linux host or VM running the containers.

Fix the exception or memory pressure shown by the evidence. If a legitimate workload exceeds its measured allocation, change the container’s --memory limit in the deployment configuration and investigate growing memory use. Do not disable the OOM killer as an empty-response fix. For deploy-time failures, stop routing new requests to a terminating worker and allow in-flight requests to finish.

2. The proxy or client uses the wrong protocol or port

Probe the listener using the protocol it is configured to speak:

curl --noproxy '*' -v --http1.1 http://127.0.0.1:3000/
curl --noproxy '*' -v --http1.1 https://example.com:443/
sudo nginx -T 2>&1 | grep -E 'listen|server_name|proxy_pass|upstream'

HTTP sent to a TLS port: the listener can close without an HTTP reply, reset the connection, or send an error. nginx specifically logs client sent plain HTTP request to HTTPS port and maps its internal error to 400. So http://example.com:443/ is not guaranteed to produce curl 52. Use https://example.com/ for that listener.

TLS sent to a plain HTTP port: the client starts with TLS handshake bytes, not an HTTP request. A cleartext HTTP listener cannot complete that handshake; the result is a TLS negotiation error or connection closure, not a successful HTTPS response. This is a candidate for ERR_SSL_PROTOCOL_ERROR, not evidence that the response body is empty.

For an HTTP app on port 3000 behind nginx, a concrete routing configuration is:

location / {
    proxy_pass http://127.0.0.1:3000;
}

Use https:// in proxy_pass only when the origin listener actually speaks TLS. If the app lives in another container, 127.0.0.1 points to nginx’s container; use the app’s service name and container port instead. Validate with nginx -t before reloading.

A missing upstream does not ordinarily explain a silent nginx response. Its upstream failure handling normally produces a gateway error if no retry succeeds. The verified log fragment upstream prematurely closed connection while reading response header from upstream identifies an app-side closure; the browser may receive 502 Bad Gateway from nginx. If the browser receives no response either, inspect the client-facing hop separately.

3. A Docker port is published, but nothing listens inside

Publishing a port forwards traffic to a container port. It does not start the application or make a loopback-only listener reachable:

docker port web
docker exec web sh -c 'ss -ltnp'
docker logs --timestamps --since 10m web

ss must be installed in the image; otherwise inspect the listener with tooling available in that container or its network namespace. If the app listens on port 3000, an example host mapping is:

docker run --name web -p 127.0.0.1:8080:3000 your-image

Bind the app to 0.0.0.0:3000 inside the container. For a Node server listening on an explicit address, that can be server.listen(3000, '0.0.0.0'). Test the host endpoint with curl -v http://127.0.0.1:8080/. A port forwarded to no listener can manifest as refusal, reset or an empty reply depending on the forwarding implementation. Diagnose the mapping and listener rather than relying on a particular error string.

4. nginx deliberately drops the request with return 444

return 444 closes the connection without response headers. A default virtual host or security rule may select it when Host or another request condition fails:

sudo nginx -T 2>&1 | grep -nE 'return[[:space:]]+444|default_server|server_name'
sudo tail -n 100 /var/log/nginx/access.log
sudo tail -n 100 /var/log/nginx/error.log

Replay the intended hostname, not just the server IP. For an HTTP listener:

curl --noproxy '*' -v -H 'Host: example.com' http://127.0.0.1:80/

An unmatched request reaching a return 444; block is expected to receive no HTTP response. Correct the request’s Host or the legitimate site’s server_name and routing. If a named route should reject with an explicit response, return 403; sends a status instead. Do not remove a deliberate default-host drop rule without identifying why the legitimate request reached it. See nginx 444.

5. A pooled connection is reused as the server closes it

RFC 9112 Section 9.5 describes this race: the server sees an idle connection while the client has begun another request. The resulting close or reset can happen before response headers arrive. Node documents it through request.reusedSocket.

Compare repeated URLs in one curl process with separate fresh processes:

curl -v --http1.1 -o /dev/null http://example.com/ -o /dev/null http://example.com/
curl -v --http1.1 -H 'Connection: close' http://example.com/ -o /dev/null

The first command permits reuse; the second starts a new connection and requests closure afterward. One successful run does not rule out an idle-time race. Reproduce the failing idle interval in the original client and log whether its socket was reused. With Node’s core HTTP client, http.get(url, { agent: false }, callback) is a focused comparison without a pooled agent.

Fix the pool’s idle lifetime relative to the server/proxy timeout and handle stale sockets. An empty reply does not prove the request was unprocessed. RFC 9110’s retry rules distinguish idempotent requests from operations that need application knowledge or deduplication before retrying.

6. Request framing triggers desync protection

If a simple GET works but a particular upload or generated request fails, compare its framing at each hop. Conflicting Content-Length and Transfer-Encoding, malformed lengths, or inconsistent parsing can trigger rejection and connection teardown. RFC 9112 Section 11.2 explains request smuggling; specific framing rules often require an error response followed by closure, not silent dropping.

Start with a clean control request:

curl -v --http1.1 https://example.com/upload -o /dev/null
# On nginx, correlate errors with the original failing request:
sudo tail -n 200 /var/log/nginx/error.log | grep -iE 'invalid|content.length|transfer.encoding'

Inspect the original client’s emitted headers and the proxy/security logs at the same time. For AWS ALB desync mitigation, check the access-log classification and reason: severe blocked requests receive 400, and certain ambiguous requests are routed with connections then closed. Connection teardown alone does not establish an empty reply or a smuggling attempt.

Fix the component emitting inconsistent framing and let the HTTP library calculate message lengths. Keep desync protections enabled; turning them off to suppress a connection error leaves the malformed request unresolved.

Verify the repair

Repeat the exact failing request through the public endpoint and directly at the app. Confirm the expected HTTP status and headers, then repeat under the condition that triggered the failure: a deploy, memory-heavy route or idle connection reuse. Correlate proxy and app logs so a successful health check cannot conceal a broken authenticated or upload path.

Frequently asked questions

What does ERR_EMPTY_RESPONSE mean?

Chrome received no response data before the connection closed. With a direct HTTP/1.1 connection, TCP can connect successfully while the peer sends no HTTP status line or headers. The closing peer may be a proxy rather than the application.

Is a 204 response an empty response error?

No. A 204 response has a status and headers even though it has no body. ERR_EMPTY_RESPONSE means the expected response never arrived.

Does HTTP on an HTTPS port always produce an empty reply?

No. A listener can close, reset, or reject the request with an HTTP response. nginx normally returns 400 for plain HTTP sent to an SSL listener. Probe the intended scheme and port rather than assuming one error string.

Why can a published Docker port still fail?

Publishing a port forwards traffic; it does not start a server inside the container. Verify the mapped container port and the application’s bind address. Depending on the forwarding path, a missing listener can produce a refusal, reset or empty reply.

Should I retry an empty reply?

An empty reply does not prove the server skipped the request. Retry only when the operation is safe to repeat or you have a way to deduplicate it. Diagnose idle connection reuse if fresh requests work but pooled requests fail.

Sources

  1. MDN: HTTP/1.x connection managementdeveloper.mozilla.org
  2. RFC 9112: message syntax, closure and request smugglingrfc-editor.org
  3. RFC 9110 Section 9.2.2: idempotent retriesrfc-editor.org
  4. Chromium: EMPTY_RESPONSE definitiongithub.com
  5. Chromium: browser error page wordinggithub.com
  6. curl: empty reply detectiongithub.com
  7. curl: command-line options and exit codescurl.se
  8. Node.js: premature close and reused socketsnodejs.org
  9. Node.js: listening on an explicit addressnodejs.org
  10. Python: RemoteDisconnecteddocs.python.org
  11. CPython: empty status-line handlinggithub.com
  12. nginx: return 444nginx.org
  13. nginx: upstream failure logging and handlinggithub.com
  14. nginx: plain HTTP on TLS port handlinggithub.com
  15. nginx: special responses map HTTP-to-HTTPS errors to 400github.com
  16. Docker: memory limits and OOM killsdocs.docker.com
  17. Docker: port publishingdocs.docker.com
  18. Docker: container logsdocs.docker.com
  19. Docker: container inspectiondocs.docker.com
  20. Docker Engine: container state fieldsgithub.com
  21. iproute2: ss listener inspectiongithub.com
  22. systemd: journalctl kernel, unit and time filtersgithub.com
  23. AWS ALB: desync mitigation behaviordocs.aws.amazon.com
  24. OpenBSD: nc manualman.openbsd.org

Keep going

Browse /search