Skip to content

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

@emorr23

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

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