vercel/ai · error

${method}: UI stream is already closed.

Error message

${method}: UI stream is already closed.

What it means

createStreamableUI returns update/append/error/done mutators that may only be used while the UI stream is open. Once done/error (or the resolver completing the stream) closes it, any further mutation calls hit assertStream and throw, prefixed with the method name.

Source

Thrown at packages/rsc/src/streamable-ui/create-streamable-ui.tsx:62

   * Once called, the UI node cannot be updated or appended anymore.
   *
   * This method is always **required** to be called, otherwise the response will be stuck in a loading state.
   */
  done(...args: [React.ReactNode] | []): StreamableUIWrapper;
};

/**
 * Create a piece of changeable UI that can be streamed to the client.
 * On the client side, it can be rendered as a normal React node.
 */
function createStreamableUI(initialValue?: React.ReactNode) {
  let currentValue = initialValue;
  let closed = false;
  let { row, resolve, reject } = createSuspendedChunk(initialValue);

  function assertStream(method: string) {
    if (closed) {
      throw new Error(method + ': UI stream is already closed.');
    }
  }

  let warningTimeout: NodeJS.Timeout | undefined;
  function warnUnclosedStream() {
    if (process.env.NODE_ENV === 'development') {
      if (warningTimeout) {
        clearTimeout(warningTimeout);
      }
      warningTimeout = setTimeout(() => {
        console.warn(
          'The streamable UI has been slow to update. This may be a bug or a performance issue or you forgot to call `.done()`.',
        );
      }, HANGING_STREAM_WARNING_TIME_MS);
    }
  }
  warnUnclosedStream();

View on GitHub (pinned to 69428b1f8b)

Solutions

  1. Guard mutations with a flag that is set when done/error is called
  2. Ensure done()/error() is called exactly once and only after all update/append calls
  3. Move long-running work so it completes before closing, or discard results after close instead of updating

Example fix

// before
stream.done();
stream.update(<NewUI />); // throws
// after
stream.update(<NewUI />);
stream.done();
Defensive patterns

Strategy: try-catch

Validate before calling

function assertOpen(stream: { closed?: boolean }) {
  if (stream.closed) throw new Error('stream already closed');
}

Try / catch

try {
  stream.update(newUI);
} catch (e) {
  if (e instanceof Error && e.message.endsWith('UI stream is already closed.')) {
    return; // discard update after close
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling stream.update(...) or stream.append(...) after stream.done() or stream.error() has been called; calling mutators after the resolver returned/completed the stream; calling a mutator twice after close in async code paths.

Common situations: Firing stream.update from a background task or setTimeout that outlives the streamed response; duplicate done() calls across branches (both success and error paths); post-response cache-refresh code attempting to push UI updates.

Related errors


AI-assisted analysis of vercel/ai@69428b1f8b (2026-08-30). Data as JSON: /api/errors/6af99a42d44fcf54. Report an issue: GitHub.