---
title: "200 Tokens a Second Locked Up My Browser: Client-Side Defense for Agent Streams"
date: "August 7, 2026"
description: "Pushing 200 tokens a second into React state locked my browser at 8 FPS. I paint at most once a frame and send keep-alives so idle proxies keep the stream open."
category: "Agent Architecture"
canonical: "https://ulukaya.dev/posts/client-runtime-agent-resilience"
---

# 200 Tokens a Second Locked Up My Browser: Client-Side Defense for Agent Streams

I streamed 200 tokens a second from a model straight into a React state variable: `setText(prev => prev + token)`. My browser's main thread locked up. React drew the page again 200 times a second. The garbage collector paused again and again, and the frame rate fell to 8 FPS.

		

The model did nothing wrong there. In my web and mobile apps, over 80% of the agent failures that users see happen before a prompt reaches a model. A connection drops without an error, a NAT device closes a quiet connection, or the client runs out of memory.

		

When an agent hung mid-task, I used to tweak the system prompt, change the temperature, or swap the model. In my production apps, the real breaks were at the transport boundary, in three ways. Too many renders froze the screen. A proxy cut the stream while the model was thinking in silence. A retry ran a tool step a second time. This essay takes them one at a time, each with its own figure. The three fixes are simple. I paint once per frame, I send keep-alive frames, and I record each step under a turn ID. I add App Check separately, so only my genuine app can reach the gateway.

		

> My production reality: An agent fails at the connection and state boundary long before it fails in model reasoning. When I deploy multi-turn agents to web and mobile users, I build my resilience layer in two places. My client stream consumer sends a Firebase App Check token with each request. My Google Cloud Run gateway sends the keep-alive frames.

	

	
	

## Too many renders: one React update per token

	

My first frontend bound the [raw streaming chunk handler](/posts/the-leaky-abstraction-vol1) straight to React state:

		

```
// ❌ ANTI-PATTERN: Re-rendering 50-file diffs on every token chunk
onChunk((chunk) => &#123;
  setText((prev) => prev + chunk);
&#125;);
```

	

Each chunk calls `setText`, and each call makes React render the component again. At 200 tokens a second, a token arrives every 5 ms. The screen shows a new frame only every 16.6 ms. So React ran three renders or more for each frame the screen could show.

	

My agent sometimes streams a 65k-token code diff or an architecture review. In React or Vue, 200 updates a second to the virtual DOM caused long garbage collection pauses. On mobile screens the frame rate dropped to 8 FPS, and some browser tabs crashed.

	

	

My fix separates the network reader from the screen. Each new token joins one string. Then `requestAnimationFrame` paints that string at most once per display frame, not once per token. [Update for Decisions, Not Freshness: Goal-Oriented Status Updating at the Network Edge (Sep 2026)](https://arxiv.org/abs/2609.01082v1) makes the same point. It batches state updates on the screen's decision interval, one 16.6 ms frame, not on each packet that arrives. That keeps the client buffer from filling up.

	
	

## The quiet connection: a proxy cuts the stream

	

My agent often reasons in several steps or calls tools. Then my HTTP/2 or WebSocket connection stays open for 10 to 45 seconds while tokens stream in. In deep reasoning or a chain of tool calls, my model can wait 6 to 12 seconds before the next chunk. During that wait, no bytes cross the wire.

	

	

Carrier NAT gateways, cellular radio handoffs and corporate proxies drop a connection that stays silent for more than 10 seconds. The drop also ends every [HTTP/2 stream (IETF RFC 9113)](https://datatracker.ietf.org/doc/html/rfc9113) on that connection. My frontend gets an `ECONNRESET` or a silent end of file, and my UI hangs forever. Cloud Run itself does not cut quiet streams. Its request timeout is 300 seconds by default and up to 3,600, and a heartbeat does not extend it.

	

My fix is a keep-alive frame. My gateway sends a tiny SSE comment, `:keep-alive\n\n`, every 4 seconds. The wire is never quiet for 10 seconds, so the proxy keeps the connection open. If the connection drops anyway, my client reconnects and sends `Last-Event-ID`, the ID of the last complete event it handled. The gateway then replays the events after it.

	

I built the simulator below to show the cut in seconds. It opens on the figure's numbers: the model thinks for 12 seconds, and the proxy cuts quiet connections at 10. In the second timeline, [turn keep-alive off and let the model think for 45 seconds](#lab-idle-timeout). The proxy drops the connection. Then [turn keep-alive back on](#lab-idle-timeout).

	

*[Interactive Architecture Simulator: idle-timeout - Explore live at https://ulukaya.dev/posts/client-runtime-agent-resilience#lab-idle-timeout]*

	
	

## The step that runs twice: a retry with no turn ID

	

Some of my agent turns run a chain of tool steps on the server. Say my connection drops while the server runs step 3 of a 4-step tool chain. In step 3 it creates a [Cloud Firestore](https://firebase.google.com/docs/firestore/manage-data/transactions) document, and then it calls an external Stripe webhook. My client sees only the drop. It assumes the whole operation failed, and it sends Turn 1 again.

	
			
			
			
			
	

	

The server never told my client that step 3 had finished. So the retry runs step 3 again and writes a duplicate. Mobile apps make this worse. When the OS moves an app to the background, or memory runs low, the app's active buffers break. [mzCache: On-Device LLM Memory Management under Multitasking (Sep 2026)](https://arxiv.org/abs/2609.01338v1) shows this, so the app has to restore its state in the background. Without a turn ID that the server checks, each resubmitted turn adds duplicate writes. The backend state stops being consistent.

	

My fix gives every turn an ID. My client makes a random turn ID and sends it in an `X-Stream-Turn-Id` header. A retry sends the same ID. My gateway runs each step that changes data through `runStepOnce`. That function records the step under the turn ID in the same [Firestore transaction](https://firebase.google.com/docs/firestore/manage-data/transactions) as the step's writes. So a retry skips a step that already committed. That makes each step idempotent.

	
	

## Client implementation: a resilient stream consumer

	

Below is my production TypeScript client for streaming. It uses **Firebase App Check** and reconnects with exponential backoff. It holds all three fixes: one paint per frame, an idle timer with `Last-Event-ID` resume, and the turn ID header.

	

```
import &#123; getLimitedUseToken, type AppCheck &#125; from "firebase/app-check";

const MAX_ATTEMPTS = 3;
const IDLE_TIMEOUT_MS = 10_000; // no byte for 10 s means two 4 s heartbeats never arrived
const EVENT_BOUNDARY = /(?:\r\n|\r(?!\n)|\n)&#123;2&#125;/; // blank line; a CR followed by LF is one CRLF

class NonRetryableError extends Error &#123;&#125;

/**
 * Resilient Stream Consumer with Last-Event-ID Resume & App Check Attestation
 */
export class ResilientAgentConsumer &#123;
  constructor(
    private readonly endpoint: string,
    private readonly appCheck: AppCheck
  ) &#123;&#125;

  public async executeStream(
    prompt: string,
    onRenderTick: (text: string) => void,
    turnId: string = crypto.randomUUID(), // pass the same ID to resubmit a failed turn
    signal?: AbortSignal
  ): Promise &#123;
    let lastEventId = ""; // ID of the last complete event, sent back on reconnect
    let text = "";
    let frame = 0;
    const render = () => &#123; frame = 0; onRenderTick(text); &#125;;

    for (let attempt = 1; ; attempt++) &#123;
      const controller = new AbortController();
      const abort = () => controller.abort();
      let idleTimer = setTimeout(abort, IDLE_TIMEOUT_MS);

      try &#123;
        // Single-use token: the gateway consumes it, so every attempt fetches a new one
        const &#123; token &#125; = await getLimitedUseToken(this.appCheck);
        const headers: Record = &#123;
          "Content-Type": "application/json",
          "X-Firebase-AppCheck": token,
          "X-Stream-Turn-Id": turnId,
        &#125;;
        if (lastEventId) headers["Last-Event-ID"] = lastEventId;

        const response = await fetch(this.endpoint, &#123;
          method: "POST",
          headers,
          body: JSON.stringify(&#123; prompt &#125;),
          signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
        &#125;);

        if (!response.ok || !response.body) &#123;
          // A 401, 403 or other 4xx fails the same way again; 408, 429 and 5xx may not
          const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
          const message = `HTTP $&#123;response.status&#125;`;
          throw retryable ? new Error(message) : new NonRetryableError(message);
        &#125;

        // TextDecoderStream holds a split multi-byte character until its last byte arrives
        const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
        let buffer = "";

        while (true) &#123;
          const &#123; done, value &#125; = await reader.read();
          // A clean EOF without a done event is still a dropped stream
          if (done) throw new Error("Stream ended before the done event");

          clearTimeout(idleTimer); // any byte, heartbeat included, proves the socket is alive
          idleTimer = setTimeout(abort, IDLE_TIMEOUT_MS);
          buffer += value;

          let match: RegExpExecArray | null;
          while ((match = EVENT_BOUNDARY.exec(buffer)) !== null) &#123;
            const event = parseSseEvent(buffer.slice(0, match.index));
            buffer = buffer.slice(match.index + match[0].length);

            if (event.type === "token") &#123;
              text += JSON.parse(event.data);
              frame ||= requestAnimationFrame(render); // at most one render per display frame
            &#125; else if (event.type === "done") &#123;
              cancelAnimationFrame(frame);
              render();
              return;
            &#125; else if (event.type === "error") &#123;
              throw new NonRetryableError(event.data); // the server ended the turn with an error
            &#125;
            // Count an event as received only once it is handled, so a failed parse is not skipped on resume
            if (event.id !== undefined) lastEventId = event.id;
          &#125;
        &#125;
      &#125; catch (err) &#123;
        if (err instanceof NonRetryableError || signal?.aborted || attempt >= MAX_ATTEMPTS) &#123;
          throw new Error(`Agent stream failed after $&#123;attempt&#125; attempt(s): $&#123;err&#125;`);
        &#125;
      &#125; finally &#123;
        clearTimeout(idleTimer);
        abort(); // release the old socket before a retry opens a new one
      &#125;

      // Exponential backoff with jitter, then resume after lastEventId
      const backoffMs = 2 ** attempt * 500 + Math.random() * 200;
      await new Promise((res) => setTimeout(res, backoffMs));
    &#125;
  &#125;
&#125;

function parseSseEvent(block: string): &#123; id?: string; type: string; data: string &#125; &#123;
  let id: string | undefined;
  let type = "message";
  const data: string[] = [];
  for (const line of block.split(/\r\n|\r|\n/)) &#123;
    if (line.startsWith(":")) continue; // :keep-alive heartbeat
    const colon = line.indexOf(":");
    const field = colon === -1 ? line : line.slice(0, colon);
    const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
    if (field === "id") id = value;
    else if (field === "event") type = value;
    else if (field === "data") data.push(value);
  &#125;
  return &#123; id, type, data: data.join("\n") &#125;;
&#125;
```

	

	
	

## Backend gateway on Cloud Run (Node.js and Firebase Admin)

	

People often ask me whether to run agent loops on the client or through a custom backend container. In my production apps, I use both, as two halves of one runtime:

	
	
		- **The client streams and proves it is my app.** It joins streamed tokens into one string and paints it at most once per display frame. It sends a [Firebase App Check](https://firebase.google.com/docs/app-check) token with every request, so the gateway serves only my genuine app.

		- **The gateway runs the tools and keeps the secrets.** It runs my multi-step tool calls, guards my private API keys, and saves state to [Cloud Firestore](https://firebase.google.com/docs/firestore/manage-data/transactions). It also sends keep-alive frames while the model reasons.

	

	

Below is my production Express middleware on Cloud Run. It checks each request's App Check token with replay protection, a beta feature that accepts each token only once. It sends standard SSE keep-alive comment frames, so reverse proxies do not time out. And it runs each step that changes data through a `runStepOnce` transaction keyed by the turn ID:

	

```
import type &#123; Request, Response, NextFunction &#125; from "express";
import &#123; getAppCheck, type VerifyAppCheckTokenResponse &#125; from "firebase-admin/app-check";
import &#123; FieldValue, type Firestore, type Transaction &#125; from "firebase-admin/firestore";

declare global &#123;
  namespace Express &#123;
    interface Request &#123; appCheckClaims?: VerifyAppCheckTokenResponse &#125;
  &#125;
&#125;

/**
 * Cloud Run Middleware: App Check Token Verification
 */
export async function verifyAppCheckMiddleware(
  req: Request, 
  res: Response, 
  next: NextFunction
): Promise &#123;
  const appCheckToken = req.header("X-Firebase-AppCheck");

  if (!appCheckToken) &#123;
    res.status(401).json(&#123; error: "Unauthorized: Missing App Check token" &#125;);
    return;
  &#125;

  let claims: VerifyAppCheckTokenResponse;
  try &#123;
    // Replay protection: this route runs tools, so each token is accepted once
    claims = await getAppCheck().verifyToken(appCheckToken, &#123; consume: true &#125;);
  &#125; catch (err) &#123;
    console.warn("App Check verification failed", err); // also surfaces a misconfigured Admin SDK
    // A bad or expired token fails the same way again; a failed call to the App Check backend may not
    const code = (err as &#123; code?: string &#125;).code ?? "";
    const badToken = code === "app-check/invalid-argument" || code === "app-check/app-check-token-expired";
    res.status(badToken ? 401 : 503).json(&#123; error: badToken ? "Unauthorized: Invalid App Check token" : "App Check unavailable" &#125;);
    return;
  &#125;
  if (claims.alreadyConsumed) &#123;
    res.status(401).json(&#123; error: "Unauthorized: App Check token already used" &#125;);
    return;
  &#125;
  req.appCheckClaims = claims;
  next();
&#125;

/**
 * Configures Cloud Run Streaming Headers & Heartbeat Keep-Alives
 */
export function setupStreamingHeaders(res: Response): NodeJS.Timeout &#123;
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no"); // For nginx-style proxies; Cloud Run streams without it
  res.flushHeaders(); // Send headers now, not with the first heartbeat 4 s later

  // Emit SSE keep-alive heartbeat comment frame every 4 seconds
  const heartbeatTimer = setInterval(() => &#123;
    if (!res.writableEnded) &#123;
      res.write(":keep-alive\n\n");
    &#125;
  &#125;, 4000);

  res.on("close", () => clearInterval(heartbeatTimer));
  res.on("finish", () => clearInterval(heartbeatTimer));

  return heartbeatTimer;
&#125;

/**
 * Runs one side-effecting step of a turn at most once per (turnId, step).
 * The step's writes and the record of the step commit in one Firestore transaction.
 * write() must only stage writes on tx: a retried transaction calls it again.
 */
export async function runStepOnce(
  db: Firestore,
  turnId: string,
  step: number,
  write: (tx: Transaction) => void
): Promise &#123;
  const turnRef = db.collection("turns").doc(turnId);
  return db.runTransaction(async (tx) => &#123;
    const turn = await tx.get(turnRef);
    if (turn.get(`steps.$&#123;step&#125;`) !== undefined) return false; // an earlier attempt committed this step
    write(tx);
    tx.set(turnRef, &#123; steps: &#123; [step]: FieldValue.serverTimestamp() &#125; &#125;, &#123; merge: true &#125;);
    return true;
  &#125;);
&#125;
```

	

	

My `/agent` route mounts this middleware. It reads `X-Stream-Turn-Id` and `Last-Event-ID`, and it calls `setupStreamingHeaders`. Then it streams through the `AgentSessionStreamGateway` from [Vol. 2](/posts/the-leaky-abstraction-vol2), with the turn ID as its session ID. Its `replayFrom(lastEventId)` resends the events the client missed. Another request may still hold the turn's lease. In that case the replay follows that request's journal until done, and it ends the response if the lease lapses. Otherwise the route takes the lease and runs each step through `runStepOnce`, so a step that already committed is skipped.

	

> My architectural takeaway: I keep transport failures apart from model reasoning failures. I pair Firebase App Check on the client with the Firebase Admin SDK on Cloud Run. I paint at most once per display frame to protect the virtual DOM. I send keep-alive comment frames (:keep-alive\n\n) to keep long streams alive through idle proxies. Each frame is 13 bytes. But an open stream is still an in-flight request that Cloud Run bills, and it ends at the request timeout either way.

	

> Architecture Blueprint and Spec: Inspect my complete Deterministic Agent Runtime Blueprint &rarr; or scaffold a production-ready specification tree with my noVibes Agent Spec Generator &rarr;

	
		

## Industry validation and benchmarks

		
			- [mzCache: On-Device LLM Memory Management under Multitasking (Sep 2026)](https://arxiv.org/abs/2609.01338v1): Confirms that mobile OS backgrounding and memory pressure disrupt active client buffers. The app needs a way to restore its state in the background, apart from the stream.

			- [Update for Decisions, Not Freshness: Goal-Oriented Status Updating at the Network Edge (Sep 2026)](https://arxiv.org/abs/2609.01082v1): Confirms that batching state updates on UI decision intervals (16.6 ms frames) prevents client buffer saturation. Batching on raw packet arrival does not.

		
	

	

## Primary research and documentation

	
		- [IETF RFC 9113: HTTP/2 Standard (Stream Multiplexing and Flow Control)](https://datatracker.ietf.org/doc/html/rfc9113)

		- [WHATWG HTML Standard: Server-Sent Events (SSE) Protocol](https://html.spec.whatwg.org/multipage/server-sent-events.html)

		- [WHATWG Streams Standard: ReadableStream and Backpressure Handling](https://streams.spec.whatwg.org/)

		- [Firebase App Check Overview and Attestation Architecture](https://firebase.google.com/docs/app-check)

		- [Google Cloud Run Response Streaming Configuration](https://cloud.google.com/run/docs/triggering/https-request)

		- [Cloud Firestore Transactions and Batched Writes](https://firebase.google.com/docs/firestore/manage-data/transactions)
