Timeout for Promise execution

2025-11-29

The other day, at $WORK I was writing some plumbing code to use deno in order to execute untrusted Python code through a Pyodide interpreter (ugh, I know), and I needed to ensure that the execution of the code would not go above a specific threshold.

Running untrusted Python code is tricky. You can spin up a VM/Container to do that with a good level of isolation, but it is heavy and not practical in some deployment scenarios. Sandboxing code in CPython is extremely tricky and PyPi sandboxing feature appears to be complete but you need to build from source and not all modules are available.

After looking around for a minute, I found Promise.race which did what I wanted to do. I asked Claude to generate a function, in Typescript, that would return either when the promise finishes, either when a certain time lapse has passed, whichever finishes first.

It came up with something like the following:

async function orTimeout<T>(p: Promise<T>, timeoutMs: number): Promise<T | unknown> {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(
      () => reject(new Error("Promise could not be executed in time.")),
      timeoutMs
    );
  });
  return Promise.race([p, timeoutPromise]);
}

At a glance, it looked ok, so I started using it. In my case, the program that I need to run through deno is extremely simple. It is basically a main function that reads the bytes from stdin and feed them to Pyodide as code to execute.

At all times, there is at most two promises in flight. One for the code being executed and one for the timeout promise that it races against.

So as soon as I ran my first experiment, I observed the following: the timeout promise is never interrupted. When executing, it means that if the code from the p promise runs faster than the defined timeoutMs, the function returns, the rest of the code is executed but then as the process is about to execute, it hangs, until the timeout promise is completed.

For instance, take the following snippet:

async function sleep(n: number) {
  return new Promise((resolve, _) => {
    setTimeout(() => resolve(null), n);
  });
}

async function main() {
  const durationArg = process.argv[2];
  if (!durationArg) {
    process.exit(1);
  }
  const workfor = parseInt(durationArg, 10);
  const p = sleep(workfor * 1000).then(() => 42);
  const r = orTimeout(p, 5000);
  r.then((res) => console.log(`Finished ${res}`))
  .catch(() => console.error("Time out"));
}

main();

And you run it through tsx, you’ll see:

> tsx src/timeout.ts 1
Finished 42

Again, if you don’t pay attention or if the timeout is short enough, you won’t notice that the program hangs, but if you time the program execution, it is obvious:

> time tsx src/timeout.ts 1
Finished 42

________________________________________________________
Executed in    5.35 secs      fish           external
   usr time  198.76 millis    0.31 millis  198.45 millis
   sys time   75.80 millis    1.31 millis   74.49 millis

Executed in 5.35 secs, there it is. That’s a trap. that’s for sure! If you add a catch on the promise generated by orTimeout and log the error, you’ll see it, but if you did not, you won’t notice that you’re leaking promises.

I’ve dealt these with these kinds of scenarios in Scala, mostly through cats-effect, so a similar snippet is handled correctly:

//> using dep org.typelevel::cats-effect:3.6.3
import cats.effect._
import cats.effect.unsafe.implicits._
import scala.concurrent.duration._

val p = IO.sleep(1.seconds).map(_ => 42)
p.timeout(5.seconds)
  .flatMap(r => IO.println(s"Finished $r"))
  .unsafeRunSync()

Running that with the scala-cli runs as expected:

> time scala-cli run test.sc
Finished 42

________________________________________________________
Executed in    1.62 secs      fish           external
   usr time  504.68 millis    0.23 millis  504.45 millis
   sys time   78.28 millis    1.40 millis   76.88 millis

So to me, it was surprising that a runtime like deno/node, built on top of an event loop built for asynchronous operations, would not have primitive to deal with this simple example.

To be fair, re-writing the Scala snippet above, without cats-effect, would probably require the use of Thread.sleep(which is blocking on the JVM) along with Await.result or an Executor , so you’d get similar result.

Similarly, using a full featured like effect-ts or maybe even somethinc like RxJS.

It was a coincidence that a co-worker introduced an AbortController inside of one of our project. From there I implemented a solution that worked well for my case:

// We use the `AbortController` to stop the timeout and resolve the promise
// when the race is finished.
async function orTimeout<T>(
  p: Promise<T>,
  timeoutMs: number
): Promise<T | unknown> {
  const abort = new AbortController();
  const signal = abort.signal;

  const timeoutPromise = new Promise<void>((resolve, reject) => {
    // If for some reason the signal is already aborted, we reject directly.
    if (signal.aborted) {
      reject(signal.reason);
      return;
    }

    // Keep a handle on the timeout to clear it when we abort
    const timeout = setTimeout(() => {
      reject(new Error("Timeout exceeded."));
    }, timeoutMs);

    // When the `Promise.race` below returns, we clear the timeout and resolve the promise
    // We don't know which one _won_ but we don't care
    signal.addEventListener("abort", () => {
      clearTimeout(timeout);
      resolve();
    });
  });

  return Promise.race([p, timeoutPromise]).then((r) => {
    // When done, we abort to signal to the timeout promise that it can be resolved.
    abort.abort();
    return r;
  });
}

This function works as expected, returning as soon as p is completed and the timeout promise is resolved.

While writing the post, I was testing out the snippet I used to write it and noticed that my dummy sleep function had the exact same problem as my timeout function: uncancellable! That is, if I ran the code with tsx src/timeout.ts 7 (7 seconds being larger than the 5 second timeout), my program would just hang wait for the sleep promise to complete.