Can You Measure the Duration of a Promise?
Tags: javascript, node, deno, performance
Published: 2022-07-12 03:29:22

Yes and no.

# TL;DR

## First Attempts

You can measure the duration of a specific promise in a specific spot by manually timing its creation and resolution.

```jsx
const startInstant = performance.now();
fetch("https://httpbin.org/get").then(() => {
	const endInstant = performance.now();
	console.log(`Duration: ${endInstant-startInstant}ms`);
});
```

```bash
Duration: 447ms
```

It even works if you `await` the promise instead of using a `then` callback

```jsx
const startInstant = performance.now();
await fetch("https://httpbin.org/get");
const endInstant = performance.now();
console.log(`Duration: ${endInstant-startInstant}ms`);
```

```bash
Duration: 288ms
```

But what if you’re a generic performance library that wants to try and measure the duration of **any** promise? And do so without requiring users to change any of their application code?

You might try [monkeypatching](https://en.wikipedia.org/wiki/Monkey_patch#Definitions) the Promise API itself, by messing with its prototype.

```jsx
// The generic performance library's code

const old_promise_constructor = Promise;
const old_promise_prototype = Promise.prototype;
Promise = function() {
	const promiseObj = new old_promise_constructor(...arguments);

	promiseObj.startInstant = performance.now();

	return promiseObj;
}
Promise.prototype = old_promise_prototype;

const old_then = Promise.prototype.then;
Promise.prototype.then = function(onFulfilled) {
	const startInstant = this.startInstant;

	old_then.call(this, function(value) {
			const endInstant = performance.now();
			console.log(`Start instant: ${startInstant}`);
			console.log(`End instant: ${endInstant}`);
			console.log(`Duration: ${endInstant-startInstant}ms`);

			onFulfilled(value);
	});
}

// The (untouched) application code

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('something');
  }, 300);
});

myPromise.then((value) => { console.log(`Promise resolved to ${value}`); });
```

```bash
Start instant: 223005
End instant: 223317
Duration: 312ms
Promise resolved to something
```

Which seems to do the trick…?

## The Blocker

But this won’t work if a piece of code is `await`-ing a promise, and/or if the promise is “native” (i.e. generated by a built-in function).

```jsx
const res = await fetch("https://httpbin.org/get");
```

`await`-ing makes it impossible to hook into when a promise resolves. And native promises don’t usually offer a way to hook into their creation.

So is this just impossible?

## Node.js to the Rescue

It’s possible in Node thanks to features dedicated to addressing similar problems. One example is the [PromiseHooks](https://nodejs.org/api/v8.html#promise-hooks) API, released as of v16.

```jsx
// The generic performance library's code

import { promiseHooks } from 'node:v8';

promiseHooks.onInit(function (promise) {
    promise.startInstant = performance.now();
});
promiseHooks.onSettled(function (promise) {
    console.log(`Duration ${performance.now() - promise.startInstant}ms`);
});

// The (untouched) application code

await fetch("https://httpbin.org/get"); //Needs Node v18+ to work without dependencies
```

```bash
Duration 40.9920469969511ms
Duration 0.13454999029636383ms
Duration 41.30363701283932ms
Duration 41.89799699187279ms
Duration 0.24492000043392181ms
Duration 41.59886699914932ms
Duration 228.2701609879732ms
Duration 201.04653500020504ms
Duration 229.50974099338055ms
Duration 1.0617499947547913ms
Duration 297.37966600060463ms
Duration 297.78996600210667ms
Duration 268.15292900800705ms
...
```

The results imply it’s picking up on a lot of internal promises (likely from Node/v8) on top of the one being `await`-ed in the example. That indicates it probably is capturing the duration of all promises, as desired.

(I tried to use the [AsyncLocalStorage](https://nodejs.org/api/async_context.html#asynchronous-context-tracking) and [AsyncHooks](https://nodejs.org/api/async_hooks.html#async-hooks) APIs to achieve the same goal, but couldn't figure out how to do it. [Here’s what I tried](https://github.com/Grunet/measure-duration-of-promise-examples/blob/main/asynchooksAndAsyncLocalStorage.mjs))

But what’s the story for browsers and other JS runtimes (e.g. Deno, Deno Deploy, Cloudflare Workers, Bun)? 

But first, why does any of this matter in the first place???

## Some Motivation

Application Performance Monitoring (APM) vendors (e.g. Datadog, NewRelic, etc…) often want to be able to record an app’s behavior without needing to modify its source code (a practice known as “auto instrumentation”). Where available, their libraries do this via designated hooks into code execution that the runtime exposes (e.g. via AsyncHooks in Node)

These are natural spots for them to dynamically inject their instrumentation code. But without such points of extension, it can become difficult to auto-instrument applications. 

## Some History

### The Happy Parts

For the JS ecosystem, the first recorded discussion around this I could find was [this issue from 2015 on the Chromium bug tracker](https://bugs.chromium.org/p/v8/issues/detail?id=4643). 

The discussion is about how to add hooks into the v8 JS engine around asynchronous code, to make performance monitoring easier. It also brings up closely related problems, including 

- [Angular’s usage of zone.js breaking when using not-transpiled-away async/await](https://github.com/angular/angular/issues/31730#issuecomment-294901660)
- [How Chrome DevTools constructs stack traces when asynchronous calls are involved](https://bugs.chromium.org/p/v8/issues/detail?id=4643#c20)

Out of it came a [design doc](https://docs.google.com/document/d/1rda3yKGHimKIhg5YeoAmCOtyURgsbTH_qaYR79FELlk/edit) and ultimately [a change to v8](https://v8.dev/blog/v8-release-57#promisehook) that enabled Node to land its AsyncHooks API in v8 (version 8, not the v8 runtime), the AsyncLocalStorage API in v14, and the PromiseHooks API in v16.

Today, these form the core APIs that allow APM tools to provide auto-instrumentation for Node.js applications. 

### The Sad Parts

Unfortunately, no adaptation of these changes ever saw a TC39 specification proposal make it off the ground (neither [this older one for zones](https://github.com/domenic/zones) nor [a more recent one for async context](https://github.com/legendecas/proposal-async-context)). This means that they didn’t evolve into a standards-based API for all JS runtimes to consistently implement.

Instead, there’s only Node with its bespoke API surface, with the other runtimes unable to benefit from the same APM tools*.

## Looking Forward

Deno is currently planning on [creating its own API surface](https://github.com/denoland/deno/issues/5638#issuecomment-1147780998) to the same ends, leveraging the same underlying v8 changes that Node originally did.

And doing so would enable [OpenTelemetry](https://opentelemetry.io/) (which you can imagine as a FOSS version of the vendors’ commercial APM tools) to properly function on Deno, which is actually [how I got started down this rabbit hole](https://github.com/open-telemetry/opentelemetry-js/issues/2293#issuecomment-1030882689) in the first place. 😅

Out-of-the-box, easy-to-setup instrumentation is a must-have for any modern production application. My hope is that as JS runtimes continue to evolve, they will all continue to improve on this front as well.

*P.S. Through their extensive Node compatibility efforts, [Deno now almost fully supports async context just like Node does with AsyncHooks and AsyncLocalStorage](https://github.com/open-telemetry/opentelemetry-js/issues/2293#issuecomment-1492819335)