In JavaScript, non-blocking execution is the de facto standard to stop a function call from hanging the main thread. It is typically achieved by offloading heavy work to a Web Worker or breaking the task into smaller chunks using setTimeout, queueMicrotask or a generator.
While these solutions work, they usually require you to change how you call or implement your functions. Today, we will address these limitations by approaching the problem differently using @typescript-guy/fn-monitor.
Before we start this article, let us get a quick overview of the package:
It is an instrumentation layer over a JS-in-JS interpreter that lets you monitor a function's execution at the AST level
It allows you to plug in hooks to observe and mutate a function's behavior at runtime while remaining on the same thread
It works for both synchronous and asynchronous functions.
If you ever want to dive deeper into its fundamentals, you can read the first article
To try this out locally, you can install the package from npm:
npm install @typescript-guy/fn-monitor
Making our timeout function
To begin, let us first write out our imports and custom types:
import { monitor, type Metadata } from "@typescript-guy/fn-monitor";
type milliseconds = number;
type Fn = (...args:any[])=>any
As a quick introduction to the package's API, its main export is a function called monitor, which takes a function through an object and returns a new function that runs in the custom interpreter while preserving the original function's call signature.
Let's define our timeout function and name it timeFn. It is quite long, but all you need to know is that it takes in a function along with its budget, and uses monitor() to create a new function injected with hooks to check against the budget as it executes:
function timeFn<T extends Fn>(fn:T,budget:milliseconds):T {
//We create a grace period to account for floating point errors in performance.now()
const graceTime = 0.5 as milliseconds;
//A snapshot of roughly the exact millisecond the function was called
let startTime = 0 as milliseconds;
//A volatile variable that continuously tracks how much time the function has used
let usedTime = 0 as milliseconds;
//A buffer to prevent us from checking performance.now() for every single interpreted step which will hurt its performance
let step = 0;
//This is a function that implements our budget tracking
const checkBudget = ()=>{
//UsedTime is calculated as the difference between the currentTime and the time as when the function was called
const currentTime = performance.now();
usedTime = (currentTime - startTime);
const timeIsUp = usedTime > (budget + graceTime)
if (timeIsUp) {
throw new Error(`The monitored function used ${usedTime.toFixed(3)}ms when only given a budget of ${budget.toFixed(3)}ms.`);
};
};
//The monitored function that we will return to the caller
const monitoredFn = monitor({
main:{
ref:fn,
},
//From the name, this will run before each call to our monitored function
beforeEachCall: () => {
startTime = performance.now()
usedTime = 0;
step = 0;
},
onStep:() => {
step += 1;
//Only check the budget every 1024 steps since performance.now is heavy
//we use a bitwise operator here to be fast
const shouldCheckBudget = (step & 1023) === 0;
if (shouldCheckBudget) checkBudget();
},
//From the name, this will run after each call to our monitored function
afterEachCall:(result)=>{
//if the result is an error, we let the interpreter bubble it up rather than checking the budget
if (!(result instanceof Error)) {
//in case the function doesn't use up to the number of steps required to check the budget, we check the budget here to be accurate and safe
checkBudget();
}
}
});
return monitoredFn
};
Our custom timeout uses the onStep hook instead of the inspector. You can learn more about the inspector later in the first article.
Although they are similar, they have their differences:
The
onStephook is fired before each interpreted step, while theinspectorhook is fired as the interpreter walks the AST.Unlike the
inspectorhook, it does not get the richvisitobject which is used to observe and mutate AST nodes as the function executes.
The onStep hook is exactly what our timeout needs, and the monitored function will run much faster because it prevents the interpreter from making the extra allocations that the inspector hook would have required.
With that clarified, we can use timeFn on a function that gets the price of an item. But when the item is undefined, it will lag forever trying to fetch the price:
function getPrice(item?:string):number {
if (!item) {
while (true) {
console.log('Lag');
}
}
return 10; // we just return a constant number to keep it simple
}
Calling the bare getPrice function will hang our thread as expected:
getPrice();
Output
Lag
Lag
Lag
Lag
...
But if we call a timed version, it should throw an error.
const timedGetPrice = timeFn(getPrice,50);
timedGetPrice()
Output
Lag
Lag
Lag
Lag
Error: The monitored function used 53.961ms when only given a budget of 50.000ms.
...
The timeout isn't 100% accurate because timeFn only checks the budget occasionally and the interpreter steps off while the native JS engine executes the logs. Thus, the exact millisecond it will halt is not deterministic.
But if we are being pragmatic, it is far better to lose a few milliseconds than to hang our main thread.
Our custom timeout works great for simple cases, but real-world functions rarely exist in isolation. If that function uses external variables, you have to ensure that you capture them as stated in the README.
We'll address this in a scenario where a timed function needs to call another function.
Capturing vs Embedding Functions
Assuming that we have a function that calls another function:
function getDetails(item?:string):{id?:string,price:number} {
return {
id:'id_' + item,
price:getPrice(item)
}
}
If we proceed to create a timed version and attempt to call it, it will crash and we will get a ReferenceError:
const timedGetDetails = timeFn(getDetails,50);
timedGetDetails()
Output
ReferenceError:
getPrice is not defined
-Monitored functions cannot access variables from the outside.
-They must either be passed as an argument on each call or captured/embedded upon creation.
To solve this, we will have to extend timeFn to accept a captures object and include it in the interpreter's context:
function timeFn<T extends Fn>(fn:T,budget:milliseconds,captures?:Record<string,any>):T {
//...Variable declarations and checkBudget implementation
const monitoredFn = monitor({
main:{
ref:fn,
captures
},
//...Other properties
});
return monitoredFn
}
If we now setup the timedGetDetails function with its captures and call it, we will bypass the error but we will run into another problem:
const timedGetDetails = timeFn(getDetails,50,{
getPrice
});
timedGetDetails()
Because it is captured, calling it will make it run in the native JS engine and hang our main thread.
Output
Lag
Lag
Lag
Lag
....
One way to solve this is to force it to use the timedGetPrice function by using it in the captures:
const timedGetDetails = timeFn(getDetails,50,{
getPrice:timedGetPrice
});
timedGetDetails()
When we run it, we expect timeFn to work as usual and halt it.
Output
Lag
Lag
Lag
Lag
Error: The monitored function used 58.440ms when only given a budget of 50.000ms.
...
This solves our immediate problem because not only does it allow the timedGetDetails function to call an external function without having to change its original source code, but it also allows us to put it under a strict budget.
The problem with this approach, though, is that it forces us to time every single function call within a timed function, which makes the timer fragmented — one for the outer function and one for the captured one. We can solve these problems with a more streamlined solution.
What is Embedding?
In contrast to capturing, which works for all data types and simply gives the interpreter direct references/values, embedding is exclusive to function references and it tells the interpreter to copy its source code into the same context as our monitored function and parse it together.
This allows the onStep hook for the timedGetDetails function alone to contain the entire execution under a strict budget.
This will require us to extend timeFn. We will pack both the captures and embed configurations into a single object to make it neat.
interface ExternalData {
captures?:Record<string,any>,
embed?:Record<string,Metadata<Fn>>
};
function timeFn<T extends Fn>(fn:T,budget:milliseconds,external?:ExternalData):T {
//...Variable declarations and checkBudget implementation
const monitoredFn = monitor({
main:{
ref:fn,
captures:external?.captures
},
embed:external?.embed,
//...Other properties
})
return monitoredFn
}
Then we can time our function like this:
const timedGetDetails = timeFn(getDetails,50,{
//The embed property has the same configuration structure as 'main' in the object passed to monitor()
embed:{
getPrice:{
ref:getPrice
}
}
});
Then when we call it, it runs under our budget:
timedGetDetails()
Output
Lag
Lag
Lag
Lag
Error: The monitored function used 58.161ms when only given a budget of 50.000ms.
...
Peeking at the generated code (only when you need it)
So far, how the values are captured or embedded has been treated as a black box.
But if a captured or embedded function ever behaves unexpectedly, you don't have to guess — you can
read the exact code the interpreter runs by passing an object to the sourceOut property when calling monitor.
Let us quickly add that to timeFn and extend our interface:
interface ExternalData {
//...Other properties
sourceOut?:{value:string}//Add this to the interface
};
function timeFn<T extends Fn>(fn:T,budget:milliseconds,external?:ExternalData):T {
//...Variable declarations and checkBudget implementation
const monitoredFn = monitor({
sourceOut:external?.sourceOut,
//...Other properties
})
return monitoredFn
}
Then in the timedGetDetails function:
const generatedCode = { value: "" };
const timedGetDetails = timeFn(getDetails,50,{
embed:{
getPrice:{
ref:getPrice
}
},
sourceOut:generatedCode
});
The package overwrites the value property with the code executed by the interpreter.
The resulting code is crafted by a code generator that stitches together the injected captures and the source code of the embedded functions into a single string. The result isn't that pretty because it uses hashes to guarantee that the generated variables are collision-free.
The package ensures that the inspector and onStep hooks are only fired when executing
the actual logic of your functions and not the generated boilerplate.
When we run this, we will be able to see the generated code. We don't call timedGetDetails so that its output doesn't cut off the generated code from the logs:
console.log(generatedCode.value);
// timedGetDetails()
Output
Click to expand
'use strict'
const getDetails = (() => {
const intermediateFn_generated_1de912009fe409ac0c51bb82c6c939ecad3227fe8d36ede3aae906089a513ade =
function getDetails(item) {
return {
id:'id_' + item,
price: getPrice(item)
};
};
return intermediateFn_generated_1de912009fe409ac0c51bb82c6c939ecad3227fe8d36ede3aae906089a513ade;
})();
var getPrice;
getPrice = (() => {
const getPrice = (() => {
const intermediateFn_generated_8ce88bfc0fe7f0c48f18013aa0d9b67fdf80fbd257ce4526aaa8d0c33afbeb5c =
function getPrice(item) {
if (!item) {
while (true) {
console.log('Lag');
}
}
return 10;// we just return a constant number to keep it simple
};
return intermediateFn_generated_8ce88bfc0fe7f0c48f18013aa0d9b67fdf80fbd257ce4526aaa8d0c33afbeb5c;
})();
return getPrice;
})();
//This is the code that is ran each time the monitored function is called and the result is returned through the exports variable.
exports.generated_f6a214f7a5fcda0c2cee9660b7fc29f5649e3c68aad48e20e950137c98913a68 = getDetails(...generated_090772cf4068973daad3f715eb788d39fe2c02be42efd86de81f0e59198d6237);
💡 The exact format of the generated code may change between versions but the package ensures that it will not affect the behavior of your functions.
Conclusion
Because JavaScript is single-threaded, any code running on the main thread must finish completely before your browser can update the UI or before your server can respond to user requests, meaning that there is no seamless, single-thread solution to stop a function call from hanging the application.
This package, although providing a single-threaded solution, is not free in terms of performance. But if guaranteeing a function halts outweighs any performance overhead or if you're working in an environment that can't spawn workers — then this package is worth considering.
If you had any trouble following along, spotted a typo, or just want to show off a unique use case you built with fn-monitor, feel free to open a discussion on GitHub.
And if you're interested in using fn-monitor in your own projects, check out the main repository for the full documentation.
Top comments (0)