Skip to content

Bug Report: Abort Signal Not Working for Streaming Server Functions #4651

Closed

Description

@emorr23

Which project does this relate to?

Start

Describe the bug

Summary

Abort signals are not properly propagated to streaming server functions (response: 'raw') in TanStack Start, causing memory leaks and inability to cancel long-running streams. This affects the documented cancellation behavior for streaming responses.

Environment

  • TanStack Start Version: Latest (as of July 2025)
  • Node.js Version: 18+
  • Operating System: macOS
  • Browser: Chrome/Safari/Firefox (affects all browsers)

Problem Description

When using createServerFn with response: 'raw' to create streaming responses, the abort signal passed from the client is not properly propagated to the server function handler. This results in:

  1. Memory Leaks: Multiple concurrent streams continue running even after new queries are issued
  2. Resource Waste: Server continues processing after client has moved on
  3. Broken Cancellation: The documented abort signal pattern doesn't work for streaming

Root Cause Analysis

The issue is in /packages/start-server-core/src/server-functions-handler.ts at lines 232-238:

// PROBLEMATIC CODE:
request.signal.removeEventListener('abort', abort)

if (isRaw) {
  return response  // <-- Signal disconnected too early for streaming!
}

The Problem: For streaming responses, the abort listener is removed immediately after the response is created, but the ReadableStream continues to run with a signal that's no longer connected to the client's abort signal.

Expected Behavior: The abort listener should remain connected for the lifetime of the streaming response so that client cancellation can properly terminate the stream.

Reproduction Steps

1. Create a Streaming Server Function

import { createServerFn } from "@tanstack/react-start";

export const streamSearchFn = createServerFn({
  method: "GET",
  response: "raw",
})
  .validator((input: { query: string }) => input)
  .handler(async ({ data, signal }) => {
    console.log(`🚀 Server: Starting stream for query: "${data.query}"`);
    
    // This should receive abort events but doesn't
    signal.addEventListener("abort", () => {
      console.log(`🛑 Server: Abort signal received for "${data.query}"`);
    });

    const stream = new ReadableStream({
      async start(controller) {
        let count = 0;
        const intervalId = setInterval(() => {
          // This check never triggers because signal.aborted stays false
          if (signal.aborted) {
            console.log(`🛑 Server: Stream cancelled for "${data.query}"`);
            clearInterval(intervalId);
            controller.close();
            return;
          }

          const result = JSON.stringify({
            id: `result-${count++}`,
            name: `Result ${count} for "${data.query}"`,
            timestamp: new Date().toISOString(),
          });

          controller.enqueue(new TextEncoder().encode(result + "\n"));

          if (count >= 10) {
            clearInterval(intervalId);
            controller.close();
          }
        }, 1000);
      },
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    });
  });

2. Client Usage with Abort Signal

function useStreamingSearch(query: string) {
  const abortControllerRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Cancel previous stream
    if (abortControllerRef.current) {
      console.log("🔄 Client: Cancelling previous stream");
      abortControllerRef.current.abort(); // This should cancel the server stream
    }

    const abortController = new AbortController();
    abortControllerRef.current = abortController;

    async function fetchStream() {
      const response = await streamSearchFn({
        data: { query },
        signal: abortController.signal, // Passed to server but not propagated
      });

      const reader = response.body?.getReader();
      // ... streaming logic
    }

    fetchStream();
  }, [query]);
}

3. Observed Behavior

Client logs:

🔄 Client: Cancelling previous stream for new query
🚀 Client: Starting stream for query: "new query"

Server logs:

🚀 Server: Starting stream for query: "old query"
🚀 Server: Starting stream for query: "new query"
📤 Server: Sending result 1 for "old query"    // ❌ Should be cancelled!
📤 Server: Sending result 1 for "new query"
📤 Server: Sending result 2 for "old query"    // ❌ Still running!
📤 Server: Sending result 2 for "new query"

Expected server logs:

🚀 Server: Starting stream for query: "old query"
🛑 Server: Abort signal received for "old query"  // ✅ Should happen
🚀 Server: Starting stream for query: "new query"
📤 Server: Sending result 1 for "new query"

Non-Streaming Server Functions Work Correctly

The same abort signal pattern works perfectly for non-streaming server functions:

const regularServerFn = createServerFn()
  .validator((input: { query: string }) => input)
  .handler(async ({ data, signal }) => {
    console.log(`🚀 Server: Starting regular function for query: "${data.query}"`);
    
    // This DOES work for regular functions
    signal.addEventListener("abort", () => {
      console.log(`🛑 Server: Abort signal received for "${data.query}"`);
    });

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        resolve(`Result for ${data.query}`);
      }, 2000);

      signal.addEventListener("abort", () => {
        clearTimeout(timeout);
        reject(new Error("Aborted"));
      });
    });
  });

Impact

This bug affects:

  • Performance: Memory leaks from uncancelled streams
  • User Experience: Inability to cancel long-running searches
  • Resource Usage: Server continues processing unnecessary work
  • Documentation Accuracy: The documented cancellation pattern doesn't work for streaming

Proposed Fix

In /packages/start-server-core/src/server-functions-handler.ts, change lines 232-238 from:

// CURRENT (BROKEN):
request.signal.removeEventListener('abort', abort)

if (isRaw) {
  return response
}

To:

// PROPOSED FIX:
if (isRaw) {
  // Keep abort listener connected for streaming responses
  return response
}

// Only remove listener for non-streaming responses
request.signal.removeEventListener('abort', abort)

Test Cases

The fix should ensure these test cases pass:

Test 1: Streaming Abort Signal

test('streaming server function receives abort signal', async () => {
  const abortController = new AbortController();
  let abortReceived = false;
  
  const streamFn = createServerFn({ response: 'raw' })
    .handler(async ({ signal }) => {
      signal.addEventListener('abort', () => {
        abortReceived = true;
      });
      
      return new Response(new ReadableStream({
        start(controller) {
          const interval = setInterval(() => {
            if (signal.aborted) {
              clearInterval(interval);
              controller.close();
              return;
            }
            controller.enqueue(new TextEncoder().encode('data\n'));
          }, 100);
        }
      }));
    });

  const responsePromise = streamFn({ signal: abortController.signal });
  
  setTimeout(() => abortController.abort(), 50);
  
  await responsePromise;
  expect(abortReceived).toBe(true);
});

Test 2: Multiple Concurrent Streams

test('cancelling previous stream before starting new one', async () => {
  const streams = [];
  
  for (let i = 0; i < 3; i++) {
    const controller = new AbortController();
    if (streams.length > 0) {
      streams[streams.length - 1].controller.abort();
    }
    
    streams.push({
      controller,
      response: streamFn({ signal: controller.signal })
    });
  }
  
  // Only the last stream should still be active
  // Previous streams should be cancelled
});

References

Additional Notes

  • The existing abort signal tests only cover non-streaming server functions
  • This issue affects both React and Solid Start implementations
  • The problem appears to be framework-level, not user code
  • Current workarounds (like using Node.js request 'close' events) are not portable or reliable

Your Example Website or App

google.com

Steps to Reproduce the Bug or Issue

See above

Expected behavior

See above

Screenshots or Videos

No response

Platform

  • Router / Start Version: [e.g. 1.121.0]
  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Browser Version: [e.g. 91.1]
  • Bundler: [e.g. vite]
  • Bundler Version: [e.g. 7.0.0]

Additional context

No response

Activity

  1. schiller-manuel commented on Jul 14, 2025

    @schiller-manuel
    Collaborator

    is this a duplicate of #3490 ?

    in any case, adding failing e2e test as a PR would help us!

  2. emorr23 commented on Jul 14, 2025

    @emorr23
    Author

    is this a duplicate of #3490 ?

    No, using GET as proposed does not fix the issue either. This issue identifies the problematic code and suggests a fix. Not sure if it should be added there or used here.

  3. juliomuhlbauer commented on Jul 29, 2025

    @juliomuhlbauer
    Contributor
  4. ghardin1314 commented on Nov 20, 2025

    @ghardin1314

    has anyone looked at this in a bit? Running into this error when trying to mount an Effect-ts rpc server to a route

  5. cpakken commented on Dec 15, 2025

    @cpakken
    import { createFileRoute } from '@tanstack/react-router'
    import { convertToModelMessages, smoothStream, streamText, type UIMessage } from 'ai'
    
    export const Route = createFileRoute('/api/chat')({
      server: {
        handlers: {
          POST: async ({ request }) => {
            const { messages } = (await request.json()) as { messages: UIMessage[] }
    
            console.log('messages!', messages)
    
            const result = streamText({
              model: google('gemini-2.5-flash-lite'),
              system: 'You are a helpful assistant.',
              messages: convertToModelMessages(messages),
              abortSignal: request.signal,
              onAbort: () => {
                console.log('abort')
              },
            })
    
            return result.toUIMessageStreamResponse({ sendReasoning: true, sendFinish: true })
          },
        },
      },
    })

    When request.signal is put in abortSignal, it is aborted right away. onAbort runs instantly

  6. izakfilmalter commented on Dec 17, 2025

    @izakfilmalter

    This should solve it: h3js/srvx#153

    For now I have added the following to my package.json:

      "resolutions": {
        "srvx": "0.9.8"
      }

    This seems to resolve it for me.

  7. schiller-manuel commented on Dec 17, 2025

    @schiller-manuel
    Collaborator

    @izakfilmalter i bumped in https://github.com/TanStack/router/releases/tag/v1.141.6

    however does this affect the original issue of streaming?

  8. izakfilmalter commented on Dec 18, 2025

    @izakfilmalter

    @schiller-manuel I was running into this exact error with Effect Http / Rpc. Pinning srvx to 0.9.8 resolved it for me. I know that Rpc does streaming under the hood.

  9. schiller-manuel commented on Dec 18, 2025

    @schiller-manuel
    Collaborator

    @emorr23 is this resolved in the latest version then?

  10. oztune commented on Dec 19, 2025

    @oztune

    I can confirm I'm having the same issue, though it's more intermittent but reproducible when multiple requests are being cancelled at the same time. I tried bumping to v1.41.6 and also pinning srvx to 0.9.8 like is mentioned above. Both of those solutions didn't work for me.

  11. izakfilmalter commented on Dec 19, 2025

    @izakfilmalter

    @oztune dang, guess the errors were the same but different issues then.

  12. schiller-manuel commented on Dec 19, 2025

    @schiller-manuel
    Collaborator

    @oztune can you please provide a complete reproducer project?

  13. schiller-manuel commented on Dec 19, 2025

    @schiller-manuel
    Collaborator

    @oztune can you please provide a complete reproducer project?

  14. ceski23 commented on Feb 4, 2026

    @ceski23

    @schiller-manuel here (or here in CodeSandbox) is some minimal repro. If you reload page or click Reset feed you can see in server logs that old stream is still producing values

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions