Reading200 Tokens a Second Locked Up My Browser: Client-Side Defense for Agent Streams
10 min read

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

Tokens arrive every 5 ms, but my screen shows a new frame only every 16.6 ms.

Fifty milliseconds of one streamed reply. Ten tokens arrive, one every 5 ms, and the screen can show a new frame every 16.6 ms. Rendering on every token runs ten renders in that time, and my page fell to 8 FPS. Joining the tokens into one string and painting once per frame takes three paints.Ten tokens arrive in 50 ms, 200 a second, and dashed lines mark a frame every 16.6 ms. A cross marks Render every token: ten blocks, 10 renders, 8 FPS. A check marks Paint once per frame: three blocks, one on each frame line, 3 paints, 1 per frame. frame every 16.6 ms tokens 200 a second Render every token 10 renders 8 FPS Paint once per frame 3 paints 1 per frame 0 ms 16.6 33.3 50 ms Fifty milliseconds of one streamed reply. Ten tokens arrive, one every 5 ms, and the screen can show a new frame every 16.6 ms. Rendering on every token runs ten renders in that time, and my page fell to 8 FPS. Joining the tokens into one string and painting once per frame takes three paints.Ten tokens arrive in 50 ms, 200 a second, and dashed lines mark a frame every 16.6 ms. A cross marks Render every token: ten blocks, 10 renders, 8 FPS. A check marks Paint once per frame: three blocks, one on each frame line, 3 paints, 1 per frame. tokens, 200 a second Render every token 10 renders 8 FPS Paint once per frame 3 paints 1 per frame 0 ms 16.6 33.3 50 ms frame every 16.6 ms

Tokens arrive faster than the screen draws

Step 1: in 50 ms, ten tokens arrive, one every 5 ms, and the screen draws three frames, one every 16.6 ms. tokens 1 every 5 ms screen frames 1 every 16.6 ms 0 ms 16.6 33.3 50 ms

Render on every token: frames drop

Step 2: React renders once per token, ten renders in 50 ms. The page falls to 8 FPS and the frames drop. tokens 1 every 5 ms Render every token 10 renders 8 FPS screen frames frames dropped 0 ms 16.6 33.3 50 ms

Paint once per frame: frames on time

Step 3: each token joins one string, and the page paints it once per frame, three paints in 50 ms, with every frame on time. tokens 1 every 5 ms Paint once per frame text += token 3 paints 1 per frame screen frames on time 0 ms 16.6 33.3 50 ms
1 of 3
Figure 1. Fifty milliseconds of one streamed reply. Ten tokens arrive, one every 5 ms, and the screen can show a new frame every 16.6 ms. Rendering on every token runs ten renders in that time, and my page fell to 8 FPS. Joining the tokens into one string and painting once per frame takes three paints.

I paint tokens at most once per frame instead of pushing each one into React state, send keep-alives from the Cloud Run gateway, and record each step under the turn ID so a retry never reruns a committed step.

Listen to the audio overview(1:42)Fenrir Studio Voice
0:00
1:42

I streamed 200 Token A small piece of text, often part of a word. Models read, write and bill text by the token. a second from a model straight into a React state variable: setText(prev => prev + token). My browser's Main thread The one browser thread that runs the page's code and draws the screen. When it is busy, the page freezes. locked up. React drew the page again 200 times a second. The Garbage collector The part of the JavaScript engine that frees memory the page no longer uses. The page pauses while it runs. paused again and again, and the frame rate fell to 8 FPS Frames per second. How many times a second the screen shows a new picture..

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 Network address translation. A router shares one public address among many devices and forgets connections that stay quiet. 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.
FAILURE 1

Too many renders: one React update per token

My first frontend bound the raw streaming chunk handler straight to React state:

AntiPattern.tsx
TypeScript
// ❌ ANTI-PATTERN: Re-rendering 50-file diffs on every token chunk
onChunk((chunk) => {
  setText((prev) => prev + chunk);
});

Each chunk calls setText, and each call makes React Render React runs a component again and updates the page to match the new state. 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 React's copy of the page in memory. React compares it after each change, then updates the real page. caused long garbage collection pauses. On mobile screens the frame rate dropped to 8 FPS, and some browser tabs crashed.

client-runtime-agent-resilience-demoRendered scene
Schematic: React Render Thrashing vs Frame-Coalesced Rendering

My fix separates the network reader from the screen. Each new token joins one string. Then requestAnimationFrame A browser function that runs my code once, just before the screen draws its next frame. 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) 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.

FAILURE 2

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.

Time runs left to right from the last token. Without pings, the silence crosses the 10 s idle limit, the socket is cut, and the UI hangs. A keep-alive comment every 4 s resets the idle clock, so the answer arrives. 10 s idle limit model thinks 6 to 12 s No pings socket cut UI hangs Ping every 4 s ping ping answer arrives 0 s 2 s 4 s 6 s 8 s 10 s 12 s since last token Time runs left to right from the last token. Without pings, the silence crosses the 10 s idle limit, the socket is cut, and the UI hangs. A keep-alive comment every 4 s resets the idle clock, so the answer arrives. 10 s idle limit model thinks 6 to 12 s No pings socket cut UI hangs Ping every 4 s ping ping answer arrives 0 s 4 s 6 s 8 s 10 s 12 s
Figure 2. Time runs left to right from the last token. Without pings, the silence crosses the 10 s idle limit, the socket is cut, and the UI hangs. A keep-alive comment every 4 s resets the idle clock, so the answer arrives.

Carrier NAT gateways, cellular radio handoffs and corporate Proxy A machine between my app and the server that passes traffic along. Many close connections that look idle. drop a connection that stays silent for more than 10 seconds. The drop also ends every HTTP/2 stream (IETF RFC 9113) 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 A tiny message sent only to show that a connection is still in use, so nothing closes it. frame. My gateway sends a tiny SSE Server-Sent Events. A server keeps one reply open and sends text events, each ended by a blank line. 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. The proxy drops the connection. Then turn keep-alive back on.

Interactive Lab · idle-timeoutModel thinks, proxy hangs up

Let the model think in silence past a proxy idle timeout and count the seconds of dead air before the connection dies, then turn keep-alive on.

FAILURE 3

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 document, and then it calls an external Stripe Webhook A web address that another service calls when something happens, so code there can react.. My client sees only the drop. It assumes the whole operation failed, and it sends Turn 1 again.

The same drop at step 3 of a 4-step chain, then a retry. Without a turn ID, the gateway runs step 3 again and writes a second document. With the same turn ID, the gateway finds step 3 already recorded, skips it, and runs only what is left. Drop at step 3 of 4, then retry No turn ID Gateway step 3 runs again duplicate write Turn ID same turn ID Transaction step 3 runs once resumes at last ID The same drop at step 3 of a 4-step chain, then a retry. Without a turn ID, the gateway runs step 3 again and writes a second document. With the same turn ID, the gateway finds step 3 already recorded, skips it, and runs only what is left. Drop at step 3 of 4, then retry No turn ID Gateway step 3 runs again duplicate write Turn ID same turn ID Transaction step 3 runs once resumes at last ID

One turn runs four steps

Step 1: my app sends one request. The gateway runs steps 1 to 4, and step 3 writes one document to the database. My app Gateway request step 1 step 2 step 3 step 4 writes 1 document

The connection drops during step 3

Step 2: the connection drops while step 3 runs. Step 3 still finishes and writes its document. My app Gateway drops step 1 step 2 step 3 step 4 1 document running

Retry without a turn ID: step 3 again

Step 3: the retry looks like a new turn. The gateway runs step 3 again, and the database now holds two documents, a duplicate write. My app Gateway retry, new turn step 1 step 2 step 3 step 4 2 documents duplicate write

Retry with the same turn ID

Step 4: the retry sends the same turn ID. Steps 1 to 3 are already recorded, so step 3 is skipped, step 4 runs once, and the database keeps one document. My app Gateway same turn ID step 1 step 2 step 3 step 4 1 document step 3 skipped runs once
1 of 4
Figure 3. The same drop at step 3 of a 4-step chain, then a retry. Without a turn ID, the gateway runs step 3 again and writes a second document. With the same turn ID, the gateway finds step 3 already recorded, skips it, and runs only what is left.

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) 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 A random ID my app makes for one request. Every retry of that request sends the same 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 as the step's writes. So a retry skips a step that already committed. That makes each step Idempotent Safe to repeat. Running an idempotent step twice has the same effect as running it once..

PART 02

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.

client/AgentStreamConsumer.ts
TypeScript
import { getLimitedUseToken, type AppCheck } 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){2}/; // blank line; a CR followed by LF is one CRLF

class NonRetryableError extends Error {}

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

  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<void> {
    let lastEventId = ""; // ID of the last complete event, sent back on reconnect
    let text = "";
    let frame = 0;
    const render = () => { frame = 0; onRenderTick(text); };

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

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

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

        if (!response.ok || !response.body) {
          // 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 ${response.status}`;
          throw retryable ? new Error(message) : new NonRetryableError(message);
        }

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

        while (true) {
          const { done, value } = 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) {
            const event = parseSseEvent(buffer.slice(0, match.index));
            buffer = buffer.slice(match.index + match[0].length);

            if (event.type === "token") {
              text += JSON.parse(event.data);
              frame ||= requestAnimationFrame(render); // at most one render per display frame
            } else if (event.type === "done") {
              cancelAnimationFrame(frame);
              render();
              return;
            } else if (event.type === "error") {
              throw new NonRetryableError(event.data); // the server ended the turn with an error
            }
            // 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;
          }
        }
      } catch (err) {
        if (err instanceof NonRetryableError || signal?.aborted || attempt >= MAX_ATTEMPTS) {
          throw new Error(`Agent stream failed after ${attempt} attempt(s): ${err}`);
        }
      } finally {
        clearTimeout(idleTimer);
        abort(); // release the old socket before a retry opens a new one
      }

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

function parseSseEvent(block: string): { id?: string; type: string; data: string } {
  let id: string | undefined;
  let type = "message";
  const data: string[] = [];
  for (const line of block.split(/\r\n|\r|\n/)) {
    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);
  }
  return { id, type, data: data.join("\n") };
}
PART 02

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:

My app holds one event stream to the Cloud Run gateway and sends an App Check token with every request. The gateway verifies the token, keeps the API keys, and calls the model, tool APIs and Firestore for the app. A script with no token is refused at the gateway. Your app sends App Check token token verified event stream Cloud Run gateway holds the API keys Model Tool APIs Firestore Script refused no token My app holds one event stream to the Cloud Run gateway and sends an App Check token with every request. The gateway verifies the token, keeps the API keys, and calls the model, tool APIs and Firestore for the app. A script with no token is refused at the gateway. Your app sends App Check token token verified event stream Cloud Run gateway holds the API keys Model Tool APIs Firestore Script no token refused
Figure 4. My app holds one event stream to the Cloud Run gateway and sends an App Check token with every request. The gateway verifies the token, keeps the API keys, and calls the model, tool APIs and Firestore for the app. A script with no token is refused at the gateway.
  • 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 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. 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:

server/cloudRunMiddleware.ts
TypeScript
import type { Request, Response, NextFunction } from "express";
import { getAppCheck, type VerifyAppCheckTokenResponse } from "firebase-admin/app-check";
import { FieldValue, type Firestore, type Transaction } from "firebase-admin/firestore";

declare global {
  namespace Express {
    interface Request { appCheckClaims?: VerifyAppCheckTokenResponse }
  }
}

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

  if (!appCheckToken) {
    res.status(401).json({ error: "Unauthorized: Missing App Check token" });
    return;
  }

  let claims: VerifyAppCheckTokenResponse;
  try {
    // Replay protection: this route runs tools, so each token is accepted once
    claims = await getAppCheck().verifyToken(appCheckToken, { consume: true });
  } catch (err) {
    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 { code?: string }).code ?? "";
    const badToken = code === "app-check/invalid-argument" || code === "app-check/app-check-token-expired";
    res.status(badToken ? 401 : 503).json({ error: badToken ? "Unauthorized: Invalid App Check token" : "App Check unavailable" });
    return;
  }
  if (claims.alreadyConsumed) {
    res.status(401).json({ error: "Unauthorized: App Check token already used" });
    return;
  }
  req.appCheckClaims = claims;
  next();
}

/**
 * Configures Cloud Run Streaming Headers & Heartbeat Keep-Alives
 */
export function setupStreamingHeaders(res: Response): NodeJS.Timeout {
  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(() => {
    if (!res.writableEnded) {
      res.write(":keep-alive\n\n");
    }
  }, 4000);

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

  return heartbeatTimer;
}

/**
 * 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<boolean> {
  const turnRef = db.collection("turns").doc(turnId);
  return db.runTransaction(async (tx) => {
    const turn = await tx.get(turnRef);
    if (turn.get(`steps.${step}`) !== undefined) return false; // an earlier attempt committed this step
    write(tx);
    tx.set(turnRef, { steps: { [step]: FieldValue.serverTimestamp() } }, { merge: true });
    return true;
  });
}

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, 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 A lock with an expiry time. One request holds it while it works, and it lapses if the request dies.. 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 → or scaffold a production-ready specification tree with my noVibes Agent Spec Generator →

Industry validation and benchmarks

REFERENCES

Primary research and documentation

First published 7 Aug 2026 · last revised 8 Oct 2026 · 37 revisions

Hand this to your agent

You

Read this essay by Ibrahim Ulukaya, https://ulukaya.dev/posts/client-runtime-agent-resilience, and look at my project. Where does my client push streamed tokens into state on every chunk or let a resubmit rerun its tools, and where do I need a ring buffer, gateway keep-alives, and idempotent turn IDs? Name the file and line for each finding.

Cited by
  1. Two Writers, One Index: How Static Files Corrupt Agent Memory

    ……per client transport failure patterns, read my guide on Client-Side Runtime Agent Resilience . 04 Trap 3: Phantom state loops and stale cache trust……

  2. Instrument: Memory-Safety Territory Automaton (2,304 Cells)

    Run a 60 FPS cellular automaton over a 64x36 Uint8Array grid where bounds-checked memory-safe territory fights unchecked mutating territory, and toggle a bounds-check shield to flip containment.

  3. Instrument: Model thinks, proxy hangs up

    Let the model think in silence past a proxy idle timeout and count the seconds of dead air before the connection dies, then turn keep-alive on.