![]() |
VOOZH | about |
One of the biggest challenges in writing frontend code or Node.js code is dealing with asynchronicity. There was an original generator revolution when packages like co allowed us to write synchronous looking async code with normal constructs like try and catch:
co.wrap(function*() {
try {
yield fetch('http://some.domain');
} catch(err) {
// handle
}
});
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
Around this time, C# and .net started shipping the original async...await construct that flattened async code into a more familiar shape:
public static async Task Main()
{
Task<int> downloading = DownloadDocsMainPageAsync();
int bytesLoaded = await downloading;
Console.WriteLine($"{nameof(Main)}: Downloaded {bytesLoaded} bytes.");
}
Some very clever people decided that JavaScript should adopt async...await keywords into the JavaScript language. Babel and regenerator transpiled the keyword constructs into code that used generators to achieve the async workflow. Nodejs went one step further and made async...await a first-class language citizen.
What makes async...await code so appealing is that it looks synchronous. The code appears to stop and wait until a response returns or an error occurs. Code can be wrapped in a familiar try..catch block.
async...await gained a lot of traction, and the generator revolution was overlooked for the more limited async...await.
What makes JavaScript generator functions so different is that they do not initially execute, and instead they return an iterator object with a next function. Execution in the function can suspend and resume at exactly the point that it was suspended in between next calls.
I have been using the npm package thefrontside/effection for some time now.
Effection utilizes the magic of generators to allow us to write code like this:
run(function* () {
let socket = new WebSocket('ws://localhost:8080');
yield throwOnErrorEvent(socket);
yield once(socket, "open");
let messages = yield once(socket, "message");
while(true) {
let message = yield messages.next();
console.log('Got message:', message);
}
});
There are some beautiful abstractions in the code above that ease the path to writing less code and simpler code.
For example:
yield once(socket, "open");
The above code states that execution cannot proceed until the websocket open event has occurred.
If we were doing this in normal JavaScript, it would look something like this:
const remove = socket.addEventListener('open', (event) => {
// proceed
});
Let us take a quick recap on what makes generators so powerful.
A generator function is an iterator that returns an object that we can call next on. A generator appears to be a function, but it behaves like an iterator.
What makes generators so powerful is their ability to suspend and resume execution.
The everySingleEvenNumber generator function below illustrates this capability:
function* everySingleEvenNumber() {
let i = 0;
while (true) {
yield i += 2;
}
}
var gen = everySingleEvenNumber();
console.log(gen.next().value); // 2
console.log(gen.next().value); // 4
console.log(gen.next().value); // 6
console.log(gen.next().value); // 8
The while (true) construct looks like an infinite loop, but execution is suspended after each yield and only resumed when the iterator next function gets called in the console.log code.
The current value of the local i variable does not reset between each call and is maintained.
Generators differ from async/await, where execution vanishes and only returns when a promise resolves or rejects.
The ability to suspend and resume functions opens up many more doors than async/await has shut closed in its rapid adoption.
effection allows you to spawn separate processes as generator functions and take care of the teardown of all child processes started with effection. This technique is known as structured concurrency.
Effection exposes a task object that can spawn new detached processes:
main(function* (task: Task) {
console.log('in main');
task.spawn(function* () {
while (true) {
yield sleep(100);
console.log('awake');
}
});
yield;
})
Below is a flakyConnection function that will not connect until the fifth attempt:
let attempt = 1;
function flakyConnection(): Promise<{ connected: boolean }> {
return new Promise<{ connected: boolean }>((resolve) => {
setTimeout(() => {
attempt++;
resolve({ connected: attempt === 5 });
}, 100);
});
}
To get a connection, a client will have to attempt five times before being successful. Good client code will also include a timeout and throw an exception if the operation takes too long.
Writing polling code that times out is annoying code to write, but effection and the suspend and resume qualities of generators make this a very nice experience:
main(function* (parent: Task) {
parent.spawn(function* (child) {
child.spawn(function* () {
console.log('primed to throw an Error');
yield sleep(8000);
throw new Error('you are out of time! Better luck next time.');
});
while (true) {
console.log(`connection attempt ${attempt}...`);
const { connected } = yield flakyConnection();
if (connected) {
console.log('we are connected!');
return true;
}
console.log('no cigar, we try again');
yield sleep(2000);
}
});
yield;
});
A new process is attached to the parent task object made available through main.
The code below elegantly takes care of setting a timeout that will throw an exception if the client cannot connect after 8000 milliseconds:
child.spawn(function* () {
console.log('primed to throw an Error');
yield sleep(8000);
throw new Error('you are out of time! Better luck next time.');
});
The effection sleep function will suspend execution for 8000 milliseconds. If the parent process still exists after 8000 milliseconds, then it will throw an exception.
The code below will attempt to connect in 200 millisecond intervals until it is successful:
while (true) {
console.log(`connection attempt ${attempt}...`);
const { connected } = yield flakyConnection();
if (connected) {
console.log('we are connected!');
return true;
}
console.log('no cigar, we try again');
yield sleep(300);
}
This code above can keep executing until a connection occurs or the timeout exception throws at which stage effection will close down all child processes.
Running the above code results in this output:
primed to throw an Error connection attempt 1... no cigar, we try again connection attempt 2... no cigar, we try again connection attempt 3... no cigar, we try again connection attempt 4... we are connected!
Here is a repo with the above code.
You can check if the timeout works by changing the timeout code to something like this:
child.spawn(function* () {
console.log('primed to throw an Error');
yield sleep(4000);
throw new Error('you are out of time! Better luck next time.');
});
The timeout occurring results in this output:
primed to throw an Error connection attempt 1... no cigar, we try again connection attempt 2... no cigar, we try again Error: you are out of time! Better luck next time.
I still use async/await for simple one-shot async tasks with no workflow, but it is a limited paradigm.
Generator functions can solve a whole breed of problems that nothing else can. Starting and resuming threads of execution is incredibly powerful, and generators have this functionality built-in and out of the box.
Jump in! The water is warm.
Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.
LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.
π LogRocket Dashboard Free Trial BannerLogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Learn how next-browser gives AI agents runtime context for debugging Next.js apps, including React props, hydration, PPR, forms, and performance.
Build dynamic LLM routing in Next.js with OpenRouter, TanStack AI, task classification, model fallbacks, and cost-aware routing.
TSRX adds first-class control flow, conditional hooks, and scoped styles to React via a TypeScript compiler extension β no new framework required.
Learn how to build a full React Native auth system using Better Auth and Expo β with email/password login, Google OAuth, session persistence, and protected routes.
Would you be interested in joining LogRocket's developer community?
Join LogRocketβs Content Advisory Board. Youβll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now