Bug Report: Abort Signal Not Working for Streaming Server Functions
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:
- Memory Leaks: Multiple concurrent streams continue running even after new queries are issued
- Resource Waste: Server continues processing after client has moved on
- 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
- TanStack Start Docs: Server Functions - Cancellation
- TanStack Start Docs: Server Functions - Streaming
- MDN AbortSignal: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
- Existing Tests:
/e2e/react-start/server-functions/src/routes/abort-signal.tsx
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
is this a duplicate of https://github.com/TanStack/router/issues/3490 ?
in any case, adding failing e2e test as a PR would help us!
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.
Related: https://x.com/unnoqcom/status/1950200669828501658
has anyone looked at this in a bit? Running into this error when trying to mount an Effect-ts rpc server to a route
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
This should solve it: https://github.com/h3js/srvx/pull/153
For now I have added the following to my package.json:
"resolutions": {
"srvx": "0.9.8"
}
This seems to resolve it for me.
@izakfilmalter i bumped in https://github.com/TanStack/router/releases/tag/v1.141.6
however does this affect the original issue of streaming?
@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.
@emorr23 is this resolved in the latest version then?