TanStack
Persistence

Id Map

Id Map

There are only two ids to know, and mixing them up is what makes persistence look broken.

IdNamesLifetimeYou provide it
threadIdthe thing runs belong to: a conversation, or a generation slotas long as your app keeps using the same stringyes, from your own domain
runIdone execution: one streamed answer, one generation jobminted at the start, dead when it endsno, it is minted for you

Persistence stores and restores by threadId. runId is what you read when you need to talk to your own server about the execution happening right now.

tsx
import {
  fetchServerSentEvents,
  useChat,
  useGenerateImage,
} from '@tanstack/ai-react'

export function ProductPage({ productId }: { productId: string }) {
  // Chat: the thread id names a conversation.
  const support = useChat({
    threadId: `support-${productId}`,
    connection: fetchServerSentEvents('/api/chat'),
    persistence: true,
  })

  // Generation: the thread id names a slot that jobs fill.
  const hero = useGenerateImage({
    threadId: `product-${productId}-hero`,
    connection: fetchServerSentEvents('/api/generate/image'),
    persistence: true,
  })

  // Each hook also reports the id of whatever is running right now.
  return (
    <p>
      chat run {support.runId ?? 'none'}, image job {hero.runId ?? 'none'}
    </p>
  )
}

threadId: the key everything is filed under

A record is written per threadId and a reload looks it up by threadId. If the string is not identical after the reload, there is nothing to find. Pick it from your own domain, keep it stable, and restore works. Mint a fresh one on every mount and nothing ever restores.

On chat, the thread is the conversation

Every run in it contributes messages to one growing transcript, stored under the thread id. Restoring means replaying that transcript. Runs are internal detail the user never sees, they just see the conversation.

On generation, the thread is a slot

A generation job does not append to anything, it produces one result. So each job gets its own record, linked to the thread, and restore hands back the most recent job for that thread: its status, its error, its result metadata. Successive jobs for the same thing (the first attempt, the retry, the regenerate after a prompt tweak) all land in one slot, and the latest one is what the user is looking at.

That is why generation thread ids read like a place in your app rather than a conversation:

The thing on screenA good threadId
The hero image for a productproduct-${productId}-hero
The start frame of a videovideo-${videoId}-start-frame
The voice-over of a chapterchapter-${chapterId}-narration
A transcription of an uploadupload-${uploadId}-transcript
The support conversationchat-${conversationId}

Two rules follow from "one slot, latest job wins":

  • Two different things need two different ids. Point a hero-image hook and a thumbnail hook at the same thread and each restores whatever ran last, in the other's UI.
  • The same thing keeps its id forever. A regenerate is a new job in the same slot, not a new slot. That is what makes a reload land on the newest attempt.

runId: one execution

A run is everything between one RUN_STARTED and its RUN_FINISHED. It is minted fresh each time and thrown away when it ends. Both kinds of hook report the one this client has in flight, or null when there is none.

On chat, a run is one turn

It changes from turn to turn, and the mapping to what the user did is not one-to-one:

  • A whole tool loop is one run. The model calls a tool, you return a result, it calls another, it writes the final answer. However many loops the agentic cycle takes, that is a single runId.
  • One user message can produce several runs. Pausing on an interrupt ends the run; resuming continues the same turn under a new runId. Approve two tools in sequence and one message has spanned three runs. While the run sits paused waiting on that approval, nothing is in flight, so runId is null.

So useChat().runId answers "what is this client running right now", never "which message is this". In a live subscription, a run another client started is not yours to cancel, so it is not reported here.

mermaid
flowchart TB
    subgraph chat ["useChat, threadId: support-42"]
        direction LR
        c1["run r1
tool loop, one turn"] --> c2["run r2
interrupted"] --> c3["run r3
the resume of that same turn"]
    end

    subgraph gen ["useGenerateImage, threadId: product-7-hero"]
        direction LR
        g1["job g1
first attempt"] --> g2["job g2
retry"] --> g3["job g3
running"]
    end

    chat -. "one transcript, keyed by threadId" .-> cstore["messages store"]
    gen -. "one record per job, newest restores" .-> gstore["generationRuns store"]

On generation, a run is the job

One call to generate(...) is one job with one runId. There is no tool loop and no interrupt, so the mapping is exactly one-to-one: runId is the handle on the provider work currently in progress.

That makes it the id you hand your own server, because stop() only aborts the local stream. It does not stop a video render already burning credits on the provider:

tsx
import { fetchServerSentEvents, useGenerateVideo } from '@tanstack/ai-react'

export function VideoPanel({ videoId }: { videoId: string }) {
  const video = useGenerateVideo({
    threadId: `video-${videoId}-clip`,
    connection: fetchServerSentEvents('/api/generate/video'),
    persistence: true,
  })

  async function cancel() {
    // Stop the provider job server-side, then drop the local stream.
    if (video.runId) {
      await fetch(`/api/generate/video/cancel?runId=${video.runId}`, {
        method: 'POST',
      })
    }
    video.stop()
  }

  return (
    <button type="button" onClick={() => void cancel()} disabled={!video.runId}>
      Cancel
    </button>
  )
}

The same id is what a durability log is keyed by, so it is also the right thing to put in a log line when you are chasing one execution across your server.

Why restore keys on the thread, not the run

A page that just reloaded has no idea what the last runId was, so it cannot ask for it. It does know its threadId, because your app derived it from a product id, a route param, a video id. So the client presents the thread, and the store answers "here is what happened in it, and here is the run still going, if any."

Only then does the client tail that run's delivery log. Run ids stay essential one layer down, they are just never the entry point. See Threads and runs for the protocol anatomy and Resumable streams for the log itself.

Both sides use the same thread id

Persistence only works when the client and the server file under the same string. On the client that is the hook's threadId. On the server it is the activity's threadId — for generation the middleware reads it straight off the activity, so there is nothing to repeat on withGenerationPersistence:

ts
import {
  generateImage,
  generationParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
  memoryPersistence,
  withGenerationPersistence,
} from '@tanstack/ai-persistence'

const persistence = memoryPersistence()

export async function POST(request: Request) {
  const { input, threadId } = await generationParamsFromRequest('image', request)

  // No scope, nothing to file the job under, so nothing could ever hydrate it.
  // Reject instead of inventing an id.
  if (threadId === undefined) {
    return new Response('`threadId` is required', { status: 400 })
  }
  if (typeof input.prompt !== 'string') {
    throw new Error('This endpoint accepts text image prompts only.')
  }

  const stream = generateImage({
    adapter: openaiImage('gpt-image-2'),
    prompt: input.prompt,
    threadId,
    stream: true,
    middleware: [withGenerationPersistence(persistence)],
  })

  return toServerSentEventsResponse(stream)
}

The client sends its threadId on the wire for you, so the hook side is just the option. Chat is the same shape: the threadId you pass useChat is the one chatParamsFromRequest hands your route and withPersistence stores under. See Chat persistence and Generation persistence for the full wiring.

When you can skip threadId

Without persistence, threadId is optional. The hooks fall back to a generated id purely to satisfy the protocol, which requires a thread id on the wire. The run works, it just cannot be found again, which is fine for a one-shot image you show and forget.

Turn persistence on and threadId becomes required, on the hook and on the activity the middleware wraps — withGenerationPersistence throws when neither the activity nor its own threadId override supplies one. An app that cannot name the slot has nothing to restore into.

When restore does nothing

Almost always one of these:

  • The thread id changed. A crypto.randomUUID() or a useId() in it means a new key on every mount. Log the id on both sides and compare the two strings.
  • The client and server disagree. The hook files under one id, the middleware under another. They must match exactly.
  • You keyed on a run id. Nothing durable is addressable by runId alone from a fresh page load. Restore starts from the thread.
  • Byte storage is off. For generation, the record never holds media bytes, so status and error come back while result stays null. Add byte storage to get the media back too.