Skip to main content

Command Palette

Search for a command to run...

Methods to Handle Zombie Requests After the User Already Closed the Browser

Practical, in-depth methods to detect and mitigate zombie requests after users close the browser. Covers server-side detection, cancellation strategies in Java (servlets, CompletableFuture, WebSocket), infrastructure controls, performance trade-o...

Published
12 min read
Methods to Handle Zombie Requests After the User Already Closed the Browser
T

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.

1. An opening scenario (why the problem matters)

Browsers close all the time — users switch tabs, hit the X, or their laptop goes to sleep. For many web applications that seems harmless, but servers frequently continue expensive processing after the client has already disappeared. These "zombie requests" consume CPU, threads, sockets, memory and database locks. Some workloads silently complete and waste resources; worse, others leave partial state that requires expensive cleanup. In production systems the cost multiplies: thread pools block, JVM GC behavior changes, and response time for other users degrades.

This article clusters methods by detection, cooperative cancellation, safe background processing, and infrastructure-level controls. For each approach I include Java examples, detailed behavioral notes, and trade-offs so you can choose the right balance between correctness, performance, and complexity.

1.1 Core definitions and assumptions

When I say "zombie request" I mean server-side work that continues after the client intentionally or unintentionally closed the browser or navigated away so that the original HTTP/TCP connection is closed (FIN/RST) or effectively abandoned by middleboxes. We focus on HTTP and WebSocket servers written in Java (servlet containers, Netty, Spring, plain Java) and on JVM-level consequences like blocked threads and memory retention.

Key assumptions: network intermediaries (proxies, CDNs, load balancers) can buffer or terminate connections and hide client disconnects; TCP FIN/RST detection may be delayed; and application frameworks behave differently (servlets vs. async servers).

2. Detection techniques: how you know a client disappeared

2.1 TCP-level disconnects (FIN, RST)

At the socket layer a client closing the browser typically triggers a TCP FIN (graceful close) or RST (abortive). Server stacks can detect this if they attempt to write or otherwise inspect the socket. However, detection is opportunistic: if your server is CPU-bound doing computation and not writing to the socket, you won't see the FIN until you next touch the socket. Also, many load balancers terminate client connections and hold server connections open — the application may never see the client FIN.

2.2 Application-level signals: client aborts and exceptions

Servlet containers sometimes throw an IOException or a vendor-specific exception (e.g., Tomcat's ClientAbortException) when writing to a closed socket. Async APIs provide listeners for completion, timeout and error events. With WebSockets, frameworks give you lifecycle callbacks or exceptions on send.

2.3 Heartbeats and pings (SSE, WebSocket, custom)

If you control both client and server, periodic heartbeats/PINGs are a reliable way to detect liveness independent of transport behavior. They add overhead but are deterministic and work around proxy buffering.

3. Cancellation strategies (code + explanation)

We'll look at three Java-focused strategies: servlet AsyncContext cancellation, cooperative cancellation via CompletableFuture and CancellationToken, and WebSocket/heartbeat-driven cancellation. Each example includes deep explanation about when detection happens, performance implications, and edge cases.

3.1 Using Servlet 3.x AsyncContext with a listener

import javax.servlet.;import javax.servlet.http.;import java.io.IOException;import java.util.concurrent.*;public class AsyncServlet extends HttpServlet {    private final ExecutorService executor = Executors.newCachedThreadPool();    @Override    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {        final AsyncContext async = req.startAsync();        // Set a reasonable timeout for the async context        async.setTimeout(30_000); // 30s        async.addListener(new AsyncListener() {            @Override            public void onComplete(AsyncEvent event) {}            @Override            public void onTimeout(AsyncEvent event) {                // cancel background work if possible                Object attr = async.getRequest().getAttribute("workFuture");                if (attr instanceof Future) ((Future<?>) attr).cancel(true);            }            @Override            public void onError(AsyncEvent event) {                // cleanup            }            @Override            public void onStartAsync(AsyncEvent event) {}        });        Future<?> future = executor.submit(() -> {            try {                // long-running work                Thread.sleep(20_000);                HttpServletResponse r = (HttpServletResponse) async.getResponse();                r.getWriter().write("done");                async.complete();            } catch (InterruptedException e) {                // respond with cancellation if possible                try { async.getResponse().getWriter().write("cancelled"); } catch (IOException ignored) {}                async.complete();                Thread.currentThread().interrupt();            } catch (IOException ioe) {                async.complete();            }        });        req.setAttribute("workFuture", future);    }}

Explanation: - What it does: starts asynchronous processing using Servlet AsyncContext so the container thread is released quickly; schedules long work on an ExecutorService and saves the Future in the request attributes. - Detection path: async.setTimeout triggers onTimeout when no completion occurs within the configured duration. If a client disconnects and the container detects it (e.g., IOException on write), the container may invoke onError or onComplete depending on implementation; you must inspect vendor-specific exceptions sometimes. - Cancellation: onTimeout cancels the background Future. The task checks for InterruptedException to perform early exit. - Performance: this approach avoids tying up servlet threads but still uses OS threads for background work. It's straightforward to implement in existing servlet containers. - Edge cases: - If a load balancer masks client disconnects, onError/onTimeout will be your fallback, not an immediate socket exception. - Cancelling Future.cancel(true) relies on tasks checking interrupts. CPU-bound tasks without interruption points won't stop — design tasks cooperatively. - Setting timeouts too low may abort legitimate slow clients; too high and you retain resources longer.

3.2 Cooperative cancellation with a CancellationToken and CompletableFuture

import java.util.concurrent.*;import java.util.concurrent.atomic.AtomicBoolean;public class CancellationToken {    private final AtomicBoolean cancelled = new AtomicBoolean(false);    public void cancel() { cancelled.set(true); }    public boolean isCancelled() { return cancelled.get(); }}public class Work {    private final ExecutorService exec = Executors.newFixedThreadPool(10);    public CompletableFuture<String> doWork(CancellationToken token) {        return CompletableFuture.supplyAsync(() -> {            for (int i = 0; i < 1000; i++) {                // cooperative cancellation point                if (token.isCancelled()) throw new CancellationException();                // simulate chunk of work                doCpuBoundChunk(i);            }            return "ok";        }, exec);    }    private void doCpuBoundChunk(int i) {        // Example CPU work        double x = 0;        for (int j = 0; j < 10000; j++) x += Math.sqrt(j + i);    }}

Explanation: - What it does: introduces a simple CancellationToken that tasks poll, and executes work as an async CompletableFuture that periodically checks token.isCancelled(). - When to use: ideal for CPU-bound work that runs in chunks and can cooperatively check cancellation. It works well when network disconnect detection is delivered by another component (e.g., servlet listener, WebSocket lifecycle). - Performance implications: - Low overhead: the token check is cheap and predictable for tight loops. - Cancelling immediately frees CPU sooner than waiting for network timeouts. - If chunk sizes are large, you may still waste CPU inside a chunk — tune chunk granularity to balance overhead vs. responsiveness. - Edge cases: - CancellationException needs to be handled by caller to avoid leaking futures. - For work that does blocking IO (DB calls, remote RPCs), cooperative polling doesn't help unless those calls support timeouts or cancellation (e.g., reactive drivers that accept cancellation).

3.3 WebSocket ping/pong and server-side abort

import javax.websocket.;import javax.websocket.server.ServerEndpoint;import java.util.concurrent.;@ServerEndpoint("/ws")public class MyEndpoint {    private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();    private Session session;    private ScheduledFuture<?> pingTask;    @OnOpen    public void onOpen(Session session) {        this.session = session;        // schedule periodic ping to detect dead clients at application level        pingTask = scheduler.scheduleAtFixedRate(() -> {            try {                session.getBasicRemote().sendPing(java.nio.ByteBuffer.allocate(0));            } catch (Exception e) {                // failed to send ping -> consider client gone, cleanup                cleanup();            }        }, 10, 10, TimeUnit.SECONDS);    }    @OnClose    public void onClose(Session s, CloseReason r) {        cleanup();    }    private void cleanup() {        if (pingTask != null) pingTask.cancel(true);        // cancel any work tied to this session    }}

Explanation: - What it does: sends periodic WebSocket pings from server to ensure the client is responsive. If sendPing throws, you assume the client is gone and perform cleanup. - Why it's robust: works across proxies because application-level pings are processed end-to-end; you do not depend on TCP FIN visibility. - Resource trade-offs: - Additional network traffic and scheduler tasks. For many short-lived sessions these costs are small; at large scale you must size scheduler and batching carefully. - You avoid expensive timeouts when clients are clearly disconnected. - Edge cases: - Some intermediaries may reply to pings on behalf of clients (rare). Always combine ping detection with session lifecycle events and server-side timeouts.

4. Safe background processing patterns

4.1 Make operations idempotent and compensatable

If immediate cancellation is hard (e.g., you hand off work to downstream services), design operations to be idempotent. Use unique request IDs (UUIDs) so retries or late completions do not double-apply changes. When idempotency is impossible, implement compensating transactions to undo partial work.

4.2 Out-of-band processing (queues and DLQs)

One robust approach: accept the request quickly, record intent, and queue heavy work into a message broker (RabbitMQ, Kafka, SQS). Consumers process work independently. If a user disconnects the server need only update the state and optionally cancel queued work (move to a dead-letter queue). Advantages: - Decouples client lifetime from processing lifetime. - Easier to retry, monitor, and apply TTLs. Trade-offs: - Increased architecture complexity and eventual consistency. - Latency may increase because work runs asynchronously.

4.3 Expiration and TTLs

Attach a TTL to background jobs; if they aren’t claimed or completed in a window, scanners or consumer logic drop or compensate the job. This avoids indefinite resource retention and is especially important for jobs referencing expensive resources (file handles, DB locks).

5. Infrastructure and operational controls

5.1 Configure timeouts and thread pool sizing

At the container and JVM level, tune: - HTTP request/read timeouts at the load balancer and container (idle read timeout). - Socket write timeouts (if supported). - Thread pool maximum size and queue capacity. Prevent unbounded queues: they hide the fact that the server is overloaded. - Keep-alive and TCP keepalive settings for long-lived idle connections. These parameters determine how long resources survive after a client disappears.

5.2 Use non-blocking servers for high concurrency

Frameworks such as Netty, Reactor Netty, or Undertow (non-blocking IO) avoid tying up threads while waiting for IO. For high concurrency, non-blocking approaches significantly reduce the impact of zombie connections because requests that wait for network IO don't occupy dedicated threads. However, they shift complexity to ensuring your application code is reactive and supports cancellation signals.

5.3 Load balancers, proxies, and buffering caveats

Many cloud load balancers buffer request bodies or terminate TLS. They can: - Hide client disconnects from the origin, making origin servers think clients are still alive. - Deliver a FIN to origin only after the LB times out. Workarounds: set idle timeouts aligned between client-facing and origin-facing layers; use application-level heartbeats or acknowledgements; prefer protocols that minimize intermediary buffering when detection is critical.

6. Performance behavior and JVM considerations

Zombie requests impact performance in multiple ways: - Thread pool saturation: blocked threads cause request queuing and increased latency. Monitor active thread counts and queue lengths. - Memory retention: tasks capturing large objects (file buffers, DTOs) keep them alive until tasks finish or are cancelled; unexpected retention alters GC behavior and can trigger long GC pauses. - CPU waste: cancelled-but-not-interrupted CPU-bound tasks keep consuming cycles; cooperative checks are necessary. - IO resources: open file descriptors/sockets contribute to FD exhaustion.

When designing cancellation, measure: - Average time to detect disconnect (socket, ping interval, timeout). - The cost of cleaning up a cancelled task vs. letting it run to completion. - Throughput and tail latency with cancelled vs non-cancelled policies. Use load-testing tools to simulate abrupt client disconnects (e.g., open connections and close them mid-request) to see real-world effects.

  • Quick wins: - Set sensible container and LB timeouts. - Use AsyncContext in servlet apps to avoid blocking container threads. - Provide timeouts on downstream calls and DB queries. - For CPU-bound tasks: - Use cooperative cancellation tokens and break work into chunks. - Use fixed-size pools to limit the number of concurrently running chunks. - For IO-bound or long-running tasks: - Queue work off-thread (message broker) and publish progress/acknowledgement to the client when appropriate. - For interactive sessions (SSE/WebSocket): - Implement heartbeats/pings and session expiration. - When correctness matters more than immediate resource savings: - Accept longer TTLs and prefer idempotent operations and compensations rather than forced cancellation which can complicate state.

7.1 Decision matrix (brief)

  • Low-latency, short requests: set low LB timeouts + use AsyncContext. - Heavy CPU work triggered by a request: cooperative cancellation + tasks queued to worker pools. - Tasks requiring external calls (3rd-party APIs): queue + idempotency + retries. - Interactive multi-step sessions: WebSocket with pings and server-driven timeouts.
  • 8. Edge cases and gotchas

  • Middleboxes reusing connections: sometimes a proxy answers client liveness without the origin seeing it; test with your infra. - Buffered responses: write buffering can make write attempts succeed locally even if client stopped reading; flush and check for exceptions, but don't rely solely on that. - Non-interruptible native calls: JNI or some blocking DB drivers cannot be interrupted — prefer drivers with async/cancellation support. - Very short-lived clients: frequent connect/disconnect churn can make heartbeat schemes expensive — consider adaptive heartbeat rates.
  • 9. Observability and operational practices

    To manage zombie requests you must measure them: - Instrument request lifecycle: arrival, start processing, enqueue, dequeue, completion, cancel events. - Track resource metrics: active threads, queue lengths, FD counts, heap usage over time. - Add specific metrics for cancellations vs timeouts vs successful completes. - Correlate with traces (distributed tracing) to find downstream services that may still be working on abandoned requests. - Set up alerts for unusual growth in canceled or long-running tasks.

    10. Short checklist for implementation

    • Use Async APIs to free container threads quickly.
    • Instrument and set conservative timeouts at every layer.
    • Implement cooperative cancellation for CPU-bound work.
    • Design idempotent or compensating operations for eventual consistency.
    • Prefer queue-based processing for heavy or uncertain workloads.
    • Use WebSocket/SSE heartbeats for interactive sessions.
    • Test with tools that simulate abrupt disconnects and intermediary buffering.

    10.1 Example: combining AsyncContext + CancellationToken

    // Sketch combining servlet async and tokenAsyncContext async = req.startAsync();CancellationToken token = new CancellationToken();req.setAttribute("cancelToken", token);async.addListener(new AsyncListener() {    public void onTimeout(AsyncEvent e) { token.cancel(); }    public void onError(AsyncEvent e) { token.cancel(); }    // other callbacks omitted});CompletableFuture<String> work = workService.doWork(token);work.whenComplete((res, ex) -> {    if (ex instanceof CancellationException) {        // best-effort notify or log    } else {        try { async.getResponse().getWriter().write(res); } catch (IOException ignored) {}    }    async.complete();});
    Explanation: - This pattern combines request-level timeouts with cooperative job cancellation — ideal when tasks may be canceled due to timeouts or client aborts and you want both responsiveness and graceful cleanup.

    11. Final thoughts

    Handling zombie requests reliably requires thinking across layers: transport, application, and infrastructure. There is no one-size-fits-all antidote — use detection mechanisms where applicable, design for cooperative cancellation inside work units, and adopt asynchronous or queue-based architectures for heavy work. Prioritize observability so you can measure the real cost of zombies in your system and tune trade-offs accordingly.
    If you have specific constraints (e.g., legacy drivers, strict latency SLAs, or particular cloud load balancer behavior), tell me about them in the comments and I’ll suggest concrete adjustments and code snippets.

    Read more at : Methods to Handle Zombie Requests After the User Already Closed the Browser

    More from this blog

    T

    tuanh.net

    540 posts

    Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!