Skip to content

Server Fn Signal and API route (request.signal) does not actually fire 'abort' event. #3490

Closed

Description

@cpakken

Which project does this relate to?

Start

Describe the bug

When canceling a server function or fetch to API Route, either by abortController.abort() or by refreshing / closing the tab to disconnect. The signal's 'abort' event is actually never fired on the server side.

I've made a minimal reproduction here:

https://github.com/cpakken/start-signal-issue

On the terminal (server), it should console.log for 'abort' signal, but the server function will just continue its work. For computationally heavy server functions, you could use this abort event to trip a flag so that it stops any processing mid way through. Or if you pass the abort signal to a fetch called from the server, it never actually cancels it.

This might have to do with H3 Fetch and the way it implements aborts or detects when connection is closed abruptly?

Heres implementation from the minimal reproduction:

import { createServerFn } from '@tanstack/start'

export const fooServerFn = createServerFn({ method: 'POST' }).handler(async ({ signal }) => {
  signal.addEventListener('abort', () => {
    //THIS IS NEVER RUN!!!!
    //This should run when abort controller is called on the client side
    //Or when the client disconnects
    console.log('ABORTED: THIS NEVER RUNS!')
    throw new Error('aborted')
  })

  await new Promise((resolve) => setTimeout(resolve, 2000))

  //Why is this signal already aborted?!? when abort is not called?
  console.log('RETURNED SUCCESS BUT IS Signal aborted?', signal.aborted)

  return {
    data: 'SERVER FN: FOO!',
  }
})
import { json } from '@tanstack/start'
import { createAPIFileRoute } from '@tanstack/start/api'

export const APIRoute = createAPIFileRoute('/api/foo')({
  GET: async ({ request }) => {
    const { signal } = request
    signal.addEventListener('abort', () => {
      //THIS IS NEVER RUN!!!!
      //This should run when abort controller is called on the client side
      //Or when the client disconnects

      console.log('ABORTED: THIS NEVER RUNS!')
      throw new Error('aborted')
    })

    console.log('FETCHING...')

    await new Promise((resolve) => setTimeout(resolve, 2000))

    console.log('RETURNED SUCCESS BUT IS Signal aborted?', signal.aborted)

    return json({
      data: 'API: DATA IS FOO!',
    })
  },
})

I've been using this as my abort signal, but ideally the request.signal or signal passed in from createServerFn should just work

import { getEvent } from 'vinxi/http'

export const createAbortSignal = () => {
  const controller = new AbortController()

  const { res } = getEvent().node

  res.on('close', () => {
    // console.log('RES close')
    if (!res.writableEnded) {
      console.log('aborting')
      controller.abort()
    }
  })

  return controller.signal
}

Your Example Website or App

https://github.com/cpakken/start-signal-issue

Steps to Reproduce the Bug or Issue

https://github.com/cpakken/start-signal-issue

npm run dev or bun dev

Click 'fetch' button and 'abort'
Click 'fetch' button and refresh the tab to disconnect

Expected behavior

In the terminal running Start, you should see the abort event triggering

Screenshots or Videos

No response

Platform

  • OS: Windows 10
  • Browser: Edge 133.0.3065.69

Additional context

No response

Activity

  1. schiller-manuel commented on Feb 21, 2025

    @schiller-manuel
    Collaborator

    I think your server function example is incorrect.

    When running, I see ABORTED: THIS NEVER RUNS! being logged on the server logs.
    However, you throw the error from the signal's event handler; this would NOT influence the execution of the server function.

    In this example, throwing an error from the signal handler causes the node process to crash.

    Instead, you should synchronize the signal's event handler with the function execution, e.g. as shown in the docs: https://tanstack.com/start/latest/docs/framework/react/server-functions#cancellation

    I did not try the API route example, but I suspect it has the same issue.

  2. cpakken commented on Feb 21, 2025

    @cpakken
    Author

    I'm sorry, but I'm not recieving ABORTED: THIS NEVER RUNS! on my end.
    I tried on both node v23 and v22
    I updated to the latest version 1.109.2

    To be clear, on the client, it will show ABORTED and update, but on the server terminal the abort event is just not triggered.

    In this example, throwing an error from the signal handler causes the node process to crash.

    Doesn't the docs say that you can throw errors?
    https://tanstack.com/start/latest/docs/framework/react/server-functions#throwing-errors

    import { createServerFn } from '@tanstack/start'
    
    export const doStuff = createServerFn({ method: 'GET' }).handler(async () => {
      throw new Error('Something went wrong!')
    })
    
    // Usage
    function Test() {
      try {
        await doStuff()
      } catch (error) {
        console.error(error)
        // {
        //   message: "Something went wrong!",
        //   stack: "Error: Something went wrong!\n    at doStuff (file:///path/to/file.ts:3:3)"
        // }
      }
    }

    Nevertheless, I've made changes so that its using new Promise instead of async await but no difference. It's just not calling the 'abort' event for me. I don't think reject in new Promise is different than using async and throwing new Error.

    I've made the updates in
    https://github.com/cpakken/start-signal-issue

  3. schiller-manuel commented on Feb 22, 2025

    @schiller-manuel
    Collaborator

    So it seems that signal works as expected for GET server functions, but not for POST

  4. mihaiandrei97 commented on Mar 5, 2025

    @mihaiandrei97
    Contributor

    Seems it only works for GET server function. For a GET api route it is not called.

  5. alvarosevilla95 commented on May 4, 2025

    @alvarosevilla95
    Contributor

    I'm experiencing what I thing is the same issue. What I noticed, is that a serverFn POST triggered from the client seems to always have the signal in aborted status from the start of the execution.

    See this sandbox ( (modified src/utils/posts serverFn)).

    Go to /posts and refresh, then trigger a refetch from the query devtools. You will see the first time the server logs aborted: false when the serverFn is triggered from the ssr loader. When you refetch the query on the client, you will see aborted: true.

  6. mihaiandrei97 commented on Jul 9, 2025

    @mihaiandrei97
    Contributor

    Hello!

    Seems the example from the docs never aborts the signal. If you try the following code:

    import { createFileRoute } from '@tanstack/react-router'
    import { createServerFn } from '@tanstack/react-start'
    
    export const Route = createFileRoute('/')({
      component: App,
    })
    
    export const streamEvents = createServerFn({
      method: 'GET',
      response: 'raw',
    }).handler(async ({ signal }) => {
      // Create a ReadableStream to send chunks of data
    
    
      const stream = new ReadableStream({
        async start(controller) {
          // Send initial response immediately
          controller.enqueue(new TextEncoder().encode('Connection established\n'))
    
          let count = 0
          const interval = setInterval(() => {
            // Check if the client disconnected
            if (signal.aborted) {
              clearInterval(interval)
              console.log('Signal aborted, stopping stream...')
              controller.close()
              return
            }
            console.log('Sending event... Count:', count)
            // Send a data chunk
            controller.enqueue(
              new TextEncoder().encode(
                `Event ${++count}: ${new Date().toISOString()}\n`,
              ),
            )
    
            // End after 100 events
            if (count >= 100) {
              clearInterval(interval)
              controller.close()
            }
          }, 1000)
    
          // Ensure we clean up if the request is aborted
          signal.addEventListener('abort', () => {
            console.log('Request aborted, cleaning up...')
            clearInterval(interval)
            controller.close()
          })
        },
      })
    
      // Return a streaming response
      return new Response(stream, {
        headers: {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          Connection: 'keep-alive',
        },
      })
    })
    
    function App() {
      const controller = new AbortController();
      return (
        <div className="text-center">
          <header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">
            <button onClick={async () => {
              const result = await streamEvents({signal: controller.signal});
              setTimeout(() => {
                controller.abort();
             }, 5000); // Abort after 5 seconds
              const reader = result.body?.getReader();
              if (!reader) return;
              const decoder = new TextDecoder();
              while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                // Process the chunk of data
                console.log(decoder.decode(value));
              }
            }}>
              Send SSE
            </button>
          </header>
        </div>
      )
    }

    The signal is never aborted on server side. So you will see the console.log('Sending event... Count:', count) until it reaches 100.

    The fix for this seems to be to use the close event from node and dispatch the abort to stop the stream.

      const event = getEvent();
    
      event.node.res.on('close', () => {
        console.log('Response closed, cleaning up stream...');
        signal.dispatchEvent(new Event('abort')); // Abort the signal to stop the stream
      });

    Full working example:

    import { createFileRoute } from '@tanstack/react-router'
    import { createServerFn } from '@tanstack/react-start'
    import { getEvent } from '@tanstack/react-start/server'
    
    export const Route = createFileRoute('/')({
      component: App,
    })
    
    export const streamEvents = createServerFn({
      method: 'GET',
      response: 'raw',
    }).handler(async ({ signal }) => {
      // Create a ReadableStream to send chunks of data
      const event = getEvent();
    
      event.node.res.on('close', () => {
        console.log('Response closed, cleaning up stream...');
        signal.dispatchEvent(new Event('abort')); // Abort the signal to stop the stream
      });
    
      const stream = new ReadableStream({
        async start(controller) {
          // Send initial response immediately
          controller.enqueue(new TextEncoder().encode('Connection established\n'))
    
          let count = 0
          const interval = setInterval(() => {
            // Check if the client disconnected
            if (signal.aborted) {
              clearInterval(interval)
              console.log('Signal aborted, stopping stream...')
              controller.close()
              return
            }
            console.log('Sending event... Count:', count)
            // Send a data chunk
            controller.enqueue(
              new TextEncoder().encode(
                `Event ${++count}: ${new Date().toISOString()}\n`,
              ),
            )
    
            // End after 10 events
            if (count >= 100) {
              clearInterval(interval)
              controller.close()
            }
          }, 1000)
    
          // Ensure we clean up if the request is aborted
          signal.addEventListener('abort', () => {
            console.log('Request aborted, cleaning up...')
            clearInterval(interval)
            controller.close()
          })
        },
      })
    
      // Return a streaming response
      return new Response(stream, {
        headers: {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          Connection: 'keep-alive',
        },
      })
    })
    
    function App() {
      const controller = new AbortController();
      return (
        <div className="text-center">
          <header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">
            <button onClick={async () => {
              const result = await streamEvents({signal: controller.signal});
              setTimeout(() => {
                controller.abort();
             }, 5000); // Abort after 5 seconds
              const reader = result.body?.getReader();
              if (!reader) return;
              const decoder = new TextDecoder();
              while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                // Process the chunk of data
                console.log(decoder.decode(value));
              }
            }}>
              Send SSE
            </button>
          </header>
        </div>
      )
    }

    The behaviour is in both dev/prod environment.

    Tested with a plain create-tsrouter-app@latest:

    "@tanstack/react-router": "^1.121.2",
    "@tanstack/react-start": "^1.121.2",
    "@tanstack/router-plugin": "^1.121.2",
    "vite": "^6.3.3",

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions