ReadingDropped Tokens: Fixing Multi-Turn Agent Streams That Die Mid-Flight
9 min read

Dropped Tokens: Fixing Multi-Turn Agent Streams That Die Mid-Flight

A 25 s tool call goes silent, the proxy cuts it at 15 s, and the re-send runs it again.

Four steps with one 25 s tool call. The tool sends no bytes while it runs. At the 15 s idle limit a proxy cuts the socket, and a blind re-send runs the tool a second time. With a ping every 10 s and a Last-Event-ID resume, the call runs once.A dashed line marks the 15 s idle limit. A cross marks No ping: the tool, 25 s, stays silent, a socket cut comes at 15 s, and a re-send runs again, so it runs twice. A check marks Ping every 10 s with a Last-Event-ID resume: pings at 10 and 20 s carry the call to 25 s, so it runs once. 15 s idle limit No ping tool, 25 s silent socket cut re-send runs again runs twice Ping every 10 s + Last-Event-ID resume tool, 25 s ping ping runs once 0 s 5 s 10 s 15 s 20 s 25 s Four steps with one 25 s tool call. The tool sends no bytes while it runs. At the 15 s idle limit a proxy cuts the socket, and a blind re-send runs the tool a second time. With a ping every 10 s and a Last-Event-ID resume, the call runs once.A dashed line marks the 15 s idle limit. A cross marks No ping: the tool, 25 s, stays silent, a socket cut comes at 15 s, and a re-send runs again, so it runs twice. A check marks Ping every 10 s with a Last-Event-ID resume: pings at 10 and 20 s carry the call to 25 s, so it runs once. 15 s idle limit No ping tool, 25 s silent socket cut re-send runs again runs twice Ping every 10 s + Last-Event-ID resume tool, 25 s ping ping runs once 0 s 5 s 10 s 15 s 20 s 25 s

A 25 s tool call sends nothing

Step 1: one tool call runs for 25 seconds and sends no bytes. A dashed line marks the 15 second idle limit. 15 s idle limit One tool call tool, 25 s no bytes sent 0 s 5 s 10 s 15 s 20 s 25 s

At 15 s, the proxy cuts the socket

Step 2: with no ping, the socket is cut at 15 seconds. The tool keeps running, and the client is stuck loading. 15 s idle limit One tool call tool, 25 s no bytes sent socket cut tool keeps running client stuck loading 0 s 5 s 10 s 15 s 20 s 25 s

A blind re-send runs the tool twice

Step 3: the client sends the prompt again after the cut, so the tool runs a second time. 15 s idle limit One tool call tool, 25 s no bytes sent socket cut re-send runs again runs twice 0 s 5 s 10 s 15 s 20 s 25 s

A ping every 10 s keeps it open

Step 4: pings at 10 and 20 seconds keep the socket open until the tool ends at 25 seconds. With a Last-Event-ID resume, the tool runs once. 15 s idle limit Ping every 10 s + Last-Event-ID resume tool, 25 s ping ping runs once 0 s 5 s 10 s 15 s 20 s 25 s
1 of 4
Figure 1. Four steps with one 25 s tool call. The tool sends no bytes while it runs. At the 15 s idle limit a proxy cuts the socket, and a blind re-send runs the tool a second time. With a ping every 10 s and a Last-Event-ID resume, the call runs once.

I send keep-alive pings while a tool runs, journal each tool call in Firestore before it starts, and let a reconnecting client on Cloud Run replay from its Last-Event-ID, not rerun the tool.

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

My agent called a database tool that took 25 seconds. While the tool ran, my SSE Server-Sent Events. A server keeps one reply open and sends text events, each ended by a blank line. stream sent nothing. A corporate Proxy A machine between my app and the server that passes traffic along. Many close connections that look idle. sat between my client and Cloud Run Google's service that runs my containers on demand and stops them, down to zero, when no requests come.. At 15 seconds it saw no traffic and severed the idle socket. Then my client reconnected, and things got worse. The client had no Idempotent Safe to repeat. Running an idempotent step twice has the same effect as running it once. checkpoint offset, so the work ran again and caused duplicate Side effect A change a step makes outside itself, such as a new database row or an API charge..

When my Agent A program that calls a language model in a loop and lets it use tools, such as search or code. calls an external tool, it stops generating Token A small piece of text, often part of a word. Models read, write and bill text by the token.. The tool might query a database, run a compiler in a sandbox, or call a third-party API. For those seconds, zero bytes flow over the connection. A carrier NAT Network address translation. A router shares one public address among many devices and forgets connections that stay quiet. gateway or a proxy on the way sees a quiet Socket One open network connection between two programs. Either end, or a box between them, can close it. and terminates it. To defend my multi-turn agents, I send Keep-alive A tiny message sent only to show that a connection is still in use, so nothing closes it. pings while the tool runs. I also rebuild the stream from a log of numbered events, so a replay never repeats one. And I resume the session inside a Transaction Reads and writes that the database runs as one step. All of it happens, or none of it.. I run the gateway on Cloud Run and keep the event log in Firestore Google's cloud document database. It stores each record as a document and can change documents inside transactions..

An idempotent step is safe to repeat: running it twice has the same effect as running it once. A tool that writes a database row is not idempotent. So on a reconnect, my client never sends the prompt again. It sends the number of the last event it saw, in a Last-Event-ID An SSE header. A reconnecting client sends the number of the last event it got and receives only later events. header, and the server replays only the events after it.

The multi-turn reality: When a tool call in my agent takes 15 to 30 seconds, standard HTTP/1.1 and SSE connections often drop. Suppose my design keeps the stream state in memory, in short-lived backend containers. Then a reconnecting client either repeats costly tool side effects or meets state drift it cannot recover from. With no idempotency ledger, my recovery protection is $0.00.
PART 01

The architectural gap: Tool latency vs. proxy timeouts

In a standard request and response, I can put a bound on latency. A multi-turn agent run has two phases that take turns. My model streams tokens fast, and then it goes silent for a long time while a tool runs:

01

Silent proxy connection drops

Carrier NAT gateways, CDN edges and corporate proxies close a connection that stays idle too long. The limit is often between 15 and 60 seconds. My Cloud Run gateway has no idle cut like that. Its limit is the request timeout: 300 seconds by default, and up to 3,600. My agent pauses its text to wait on an external tool, such as an asynchronous BigQuery query or a database transaction with several steps. During that wait, zero bytes cross the connection.

The proxy then drops the socket without sending a TCP The protocol that carries bytes between two machines in order. It may split or join them anywhere. FIN or RST packet to my client. Those are the packets that normally close or reset a connection, so my client never learns it is gone. My client UI stays stuck in a loading state. Meanwhile my backend keeps running the work in the background.

The keep-alive rule: While a tool runs, my production streaming gateways send an SSE comment, : ping\n\n, more often than every 15 seconds. A line that starts with a colon is a comment, so the client ignores it. The traffic keeps the TCP socket state (IETF RFC 9293) active through intermediate proxies.
02

The stateless reconnection trap

Sometimes my mobile or web client switches networks, for example from Wi-Fi to cellular. Sometimes it recovers from a silent timeout. Either way, it opens a new connection. In a naive serverless design, I ran into three separate failures:

  • A different container: My reconnected request landed on a different Container One running copy of my packaged server. Cloud Run starts and stops these copies as traffic changes. instance in Google Cloud Run. That instance did not hold my previous session's stream buffer in memory.
  • The tool ran twice: My client blindly sent the original prompt again. My agent ran the non-idempotent tool calls a second time. That created duplicate database rows and duplicate API charges.
  • The UI drew everything again: My server replayed the whole conversation from the start over the new stream. My client UI stuttered, re-rendered hundreds of tokens and lost its scroll position.
PART 02

The old way vs. the new way

For my production multi-turn agents, I stopped assuming the stream lives in memory. I moved to a durable, event-sourced session transport. Event-sourced means the server writes every event to an append-only log and rebuilds the session from that log:

Failure modeThe old way (naive in-memory streaming)The new way (transactional session gateway)
Silence while a tool runs Idle tool latencySends nothing, gets cut. Zero bytes sent during tool execution; proxy drops socket after 15s.Sends a ping every 10 s. Background heartbeat emitter sends periodic SSE comments (: ping\n\n) every 10s.
The connection drops mid-reply Mid-stream disconnectThe stream is gone. Stream state lost on container recycle; client restart aborts session.Every event is saved in order. An event log in Cloud Firestore gives every event a seq number that only goes up. Tool boundaries are committed before the tool runs, and tokens in batches every 500 ms, so a crash loses only the tokens since the last commit. Each event carries a 24-hour expireAt, and a TTL Time to live. How long a stored record stays valid before it expires. policy deletes the event after that time passes.
The client comes back Reconnection ingressSends the prompt again. Client re-submits prompt, risking duplicate non-idempotent tool actions.Asks only for what it missed. Client sends the Last-Event-ID header. The gateway replays only the unacknowledged events from Firestore. If another container is still finishing the turn, the gateway keeps following the journal.
Two retries at once Tool concurrencyBoth may run the tool. Concurrent client retries trigger race conditions in parallel containers.Only the lease holder writes. The caller takes a Firestore Lease A lock with an expiry time. One request holds it while it works, and it lapses if the request dies., and every journal commit checks it in the same transaction. A container that lost the lease writes nothing, and its awaited tool_start checkpoint fails before the tool runs.
Three steps with one drop after event 3. Each event has a number. Re-sending the prompt with no journal streams events 1 to 3 again and runs the tool twice. Reconnecting with Last-Event-ID: 3 makes the session journal replay only events 4 and 5. before drop after reconnect Re-send prompt no journal 1 2 3 1 2 3 4 5 tool runs twice Last-Event-ID: 3 1 2 3 4 5 no repeats Three steps with one drop after event 3. Each event has a number. Re-sending the prompt with no journal streams events 1 to 3 again and runs the tool twice. Reconnecting with Last-Event-ID: 3 makes the session journal replay only events 4 and 5. before drop after reconnect Re-send prompt no journal 1 2 3 1 2 3 4 5 tool runs twice Last-Event-ID: 3 journal replay 1 2 3 4 5 no repeats

Each event has a number

Step 1: events are numbered 1 to 5. Events 1 to 3 reach the client, then the connection drops before 4 and 5. The client last saw event 3. before drop not sent yet Event stream numbered in order 1 2 3 4 5 client last saw 3 connection drops

Re-send the prompt, and events repeat

Step 2: with no journal, the client sends the prompt again. Events 1 to 3 stream a second time before 4 and 5, and the tool runs twice. before drop after reconnect Re-send prompt no journal 1 2 3 1 2 3 4 5 tool runs twice

Resume after event 3, with no repeats

Step 3: the client reconnects with Last-Event-ID: 3. The session journal replays only events 4 and 5, so nothing repeats. before drop after reconnect Last-Event-ID: 3 journal replay 1 2 3 4 5 no repeats
1 of 3
Figure 2. Three steps with one drop after event 3. Each event has a number. Re-sending the prompt with no journal streams events 1 to 3 again and runs the tool twice. Reconnecting with Last-Event-ID: 3 makes the session journal replay only events 4 and 5.
  • Idempotent stream consumer. It tracks the event sequence IDs, which only go up. When the transport drops, it reconnects with Exponential backoff Retrying after a wait that doubles each time, so many clients do not retry at the same moment.. It passes Last-Event-ID, so it resumes with no gap.
  • Stateful multi-turn reassembler. While a tool runs, it sends a keep-alive ping more often than every 15 s. It streams model tokens to the client and saves them in batches.
  • Event-sourced session journal. My append-only log of token chunks, tool call requests and tool results. A resume replays everything up to the last commit, and a tool boundary is committed before the tool runs.
leaky-vol2-sse-dropScreen recording
Recap from Vol. 1: 5 SSE Events Arriving as 4 TCP Packets Lose 3 Events to a Naive Parser
Show the shell command (reproduce locally)
node sse_drop.js

I built the disconnect simulator below to show how a crash in mid-flight corrupts an agent run. Drop the connection during a multi-turn stream. A blind re-send repeats every token before the drop. My idempotent session journal resumes from Last-Event-ID and repeats none:

Interactive Lab · chunk-dropStream drops on the subway

Cut the connection mid-reply and count the tokens that come back doubled, then resume from the last event id.

PART 03

Production-ready TypeScript implementation

Below is my production-tested AgentSessionStreamGateway implementation. I deploy it on Cloud Run or Firebase App Hosting. It sends heartbeats during tool calls and lets a client reconnect with Last-Event-ID. Before this class runs, the caller takes the session lease from the table above and passes its owner ID in. Every journal commit checks that ID in the same transaction. Once another owner holds the lease, the commit throws LeaseLostError. The caller awaits emit('tool_start') before it runs a tool, so a container that lost the lease stops there. A reconnect that finds another owner calls replayFrom(lastEventId, true), which follows that owner's journal until done:

AgentSessionStreamGateway.ts
TypeScript
import type { Response } from 'express';
import { Timestamp, type DocumentReference, type Firestore } from '@google-cloud/firestore';

export interface StreamEvent {
  seq: number;
  type: 'token' | 'tool_start' | 'tool_end' | 'done' | 'error';
  data: string; // JSON-encoded payload, stored and replayed byte for byte
  timestamp: number;
}

export class LeaseLostError extends Error {}

export class AgentSessionStreamGateway {
  private heartbeatTimer?: NodeJS.Timeout;
  private flushTimer?: NodeJS.Timeout;
  private currentSeq: number = 0;
  private replayed = false;
  private eventBuffer: { ref: DocumentReference; event: StreamEvent }[] = [];
  private flushChain: Promise<void> = Promise.resolve();
  private readonly REPLAY_PAGE_SIZE = 200;
  private readonly TOKEN_FLUSH_MS = 500;
  private readonly JOURNAL_TTL_MS = 24 * 60 * 60 * 1000;

  constructor(
    private readonly sessionId: string,
    private readonly res: Response,
    private readonly db: Firestore,
    private readonly ownerId: string // the ID the caller wrote to leaseOwner when it took the lease
  ) {}

  /**
   * Initializes SSE response headers and begins periodic keep-alive pings.
   */
  public initHeaders(): void {
    this.res.setHeader('Content-Type', 'text/event-stream');
    this.res.setHeader('Cache-Control', 'no-cache, no-transform');
    this.res.setHeader('Connection', 'keep-alive');
    this.res.setHeader('X-Accel-Buffering', 'no');
    this.res.flushHeaders();

    // Emit an SSE comment ping every 10 seconds to prevent proxy timeouts
    this.heartbeatTimer = setInterval(() => {
      if (this.isOpen()) {
        this.res.write(': ping\n\n');
      }
    }, 10_000);
    // Stop pinging the moment the client disconnects
    this.res.on('close', () => clearInterval(this.heartbeatTimer));

    // Persist buffered tokens every 500 ms, not only at tool boundaries
    this.flushTimer = setInterval(() => this.flushInBackground(), this.TOKEN_FLUSH_MS);
  }

  /**
   * Replays every event after lastEventId, one page at a time, and continues the
   * sequence from there so a new event never reuses an ID the client already has.
   * Call it once before emit(), with 0 for a new session.
   */
  public async replayFrom(lastEventId: number, follow = false): Promise<number> {
    let cursor = Number.isSafeInteger(lastEventId) && lastEventId > 0 ? lastEventId : 0;
    let finished = false;

    while (true) {
      const page = await this.eventsRef()
        .where('seq', '>', cursor)
        .orderBy('seq', 'asc')
        .limit(this.REPLAY_PAGE_SIZE)
        .get();

      for (const doc of page.docs) {
        const event = doc.data() as StreamEvent;
        this.writeSseFrame(event);
        cursor = event.seq;
        if (event.type === 'done' || event.type === 'error') finished = true;
      }
      if (page.size === this.REPLAY_PAGE_SIZE) continue;
      // follow: another instance owns the turn, so keep reading its journal until done
      if (!follow || finished || !this.isOpen()) break;
      await new Promise((resolve) => setTimeout(resolve, this.TOKEN_FLUSH_MS));
    }

    this.currentSeq = cursor;
    this.replayed = true;
    return cursor;
  }

  /**
   * Emits tokens instantly to client socket and buffers state for batched persistence.
   */
  public emit(type: StreamEvent['type'], payload: unknown): Promise<void> {
    if (!this.replayed) throw new Error('Call replayFrom() before emit()');
    this.currentSeq += 1;
    const event: StreamEvent = {
      seq: this.currentSeq,
      type,
      data: JSON.stringify(payload ?? null),
      timestamp: Date.now(),
    };

    // 1. Flush immediately to client socket (zero latency penalty on streaming)
    this.writeSseFrame(event);

    // 2. Buffer in memory for batched commit; the auto-ID is fixed here so a retry rewrites the same doc
    this.eventBuffer.push({ ref: this.eventsRef().doc(), event });

    // 3. Checkpoint tool boundaries now; await emit('tool_start') before the tool runs
    if (type === 'token') return Promise.resolve();
    const checkpoint = this.flushBuffer();
    checkpoint.catch((error) => console.error(`Journal flush failed for session ${this.sessionId}`, error));
    return checkpoint;
  }

  /**
   * Flushes in-flight event buffer to Firestore in one transaction that also checks the lease.
   * Commits run one at a time; a failed commit goes back into the buffer unless the lease was lost.
   */
  public flushBuffer(): Promise<void> {
    const run = this.flushChain.then(() => this.commitBuffered());
    this.flushChain = run.catch(() => {}); // one failure must not block later flushes
    return run;
  }

  private async commitBuffered(): Promise<void> {
    const pending = this.eventBuffer.splice(0);
    if (pending.length === 0) return;

    try {
      // The lease check and the writes commit together, so a container that lost the lease writes nothing
      await this.db.runTransaction(async (tx) => {
        const session = await tx.get(this.db.collection('agent_sessions').doc(this.sessionId));
        if (session.get('leaseOwner') !== this.ownerId) throw new LeaseLostError(`Lost the lease on ${this.sessionId}`);
        for (const { ref, event } of pending) {
          // A TTL policy on expireAt (exempt from indexing) deletes each event after it expires
          tx.set(ref, { ...event, expireAt: Timestamp.fromMillis(event.timestamp + this.JOURNAL_TTL_MS) });
        }
      });
    } catch (error) {
      if (!(error instanceof LeaseLostError)) this.eventBuffer.unshift(...pending); // retried on the next flush
      throw error;
    }
  }

  private flushInBackground(): void {
    this.flushBuffer().catch((error) => {
      console.error(`Journal flush failed for session ${this.sessionId}; will retry`, error);
    });
  }

  private writeSseFrame(event: StreamEvent): void {
    if (!this.isOpen()) return;
    // JSON.stringify never emits a raw newline, so one data: line carries the payload
    this.res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${event.data}\n\n`);
  }

  private isOpen(): boolean {
    // After a client disconnect, writableEnded stays false and destroyed turns true
    return !this.res.writableEnded && !this.res.destroyed;
  }

  private eventsRef() {
    return this.db.collection('agent_sessions').doc(this.sessionId).collection('events');
  }

  public async close(): Promise<void> {
    clearInterval(this.heartbeatTimer);
    clearInterval(this.flushTimer);
    try {
      // Queued behind any in-flight commit, and retried: nothing else will store the final events
      for (let attempt = 1; ; attempt++) {
        try {
          await this.flushBuffer();
          break;
        } catch (error) {
          if (attempt >= 5 || error instanceof LeaseLostError) throw error;
          await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
        }
      }
    } finally {
      if (!this.res.writableEnded) {
        this.res.end();
      }
    }
  }
}
Architectural takeaway: I do not rely on short-lived HTTP connections to carry a multi-turn agent stream. While a tool runs, I keep the socket active with keep-alive pings. I save the event stream in Cloud Firestore, with sequence IDs that only go up. After a network drop, my client reconnects with Last-Event-ID and the server recovers the state from there.
Architecture blueprint and spec: Inspect my complete Deterministic Agent Runtime Blueprint → or generate production-ready specification files with my noVibes Agent Spec Generator →

Industry validation and benchmarks

First published 18 Aug 2026 · last revised 9 Oct 2026 · 26 revisions

Hand this to your agent

You

Read this essay by Ibrahim Ulukaya, https://ulukaya.dev/posts/the-leaky-abstraction-vol2, and look at my project. Where could a dropped stream make a tool call run twice, and do I have keep-alive pings, a stream event log, and Last-Event-ID resume? Name the file and line for each finding.

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

    ……n it streams through the AgentSessionStreamGateway from Vol. 2 , with the turn ID as its session ID. Its replayFrom(la……

  2. The Hybrid AI Standard: Routing Between On-Device AI and the Cloud

    ……my UI responsiveness budget. It got worse when I tried synchronous split streaming between client and server. Mobile packet jitter stalled……

  3. Instrument: Stream drops on the subway

    Cut the connection mid-reply and count the tokens that come back doubled, then resume from the last event id.