You added async to one function buried deep in your codebase. Now every function that calls it needs async too. And every function that calls those functions. All the way up the chain, like a virus.
You've just been bitten by the function coloring problem. And the worst part? Almost nobody tells you it goes both ways.
Two colors, one iron rule
In JavaScript, functions come in two "colors": synchronous (blocking) and asynchronous (non-blocking). The rule is deceptively simple:
- An async function can call both sync and async functions.
- A sync function cannot call an async function without becoming async itself.
That second constraint is where the pain lives. If a function deep in your logic needs to become async, it forces every caller above it to also become async. One small change, and suddenly you're rewriting signatures all the way up the stack.
If loadFile() changes from sync to async, parse() must become async. Then transform(). Then build(). A single color change at the bottom cascades upward through the entire call chain.
This is what people usually mean when they talk about the "async inflection" problem. The common advice is: just make everything async, since async can call both colors.
But wait — it gets weirder.
The part nobody talks about: sync infects downward
Here's the thing most discussions of function coloring miss entirely. The problem goes both ways.
While async forces all callers upstream to become async, sync forces all dependencies downstream to be sync.
If parse() must be synchronous — maybe it's called from a context that can't be async — then loadFile() and everything it depends on must also provide synchronous implementations. The color infects downward.
At its core, it's the same constraint viewed from different angles. If a function must be async, the burden shifts to its callers. If it must be sync, the burden shifts to its dependencies.
Which direction hurts more depends on which part of the code you're focusing on and how difficult it is to change its color.
The real cost: every library duplicates itself
This isn't theoretical. Look at how popular libraries actually deal with this.
The widely used find-up package provides two APIs: findUp and findUpSync. If you look at the source, you'll find it duplicates the logic twice to support both colors. Its dependency locate-path also duplicates locatePath and locatePathSync.
Now say you want to build readNearestPkg on top of findUp. You also have to write the logic twice — once using findUp, once using findUpSync — to support both colors for your consumers.
The entire dependency pipeline is forced to branch into two parallel implementations because of a single optional async operation at the bottom (like fs.promises.stat vs. fs.statSync). Your main logic might not have any inherent "color" at all, but it gets dragged into the problem anyway.
Double the code. Double the maintenance. Double the surface area for bugs.
The plugin problem makes it worse
Here's where the coloring problem really bites in practice. Imagine you're building a Markdown-to-HTML compiler with plugin support. The parser and compiler are synchronous, so you start with a clean sync API:
export function markdownToHtml(markdown: string): string {
const ast = parse(markdown);
return render(ast);
}
You add a plugin system with hooks at each stage:
export interface Plugin {
preprocess: (markdown: string) => string;
transform: (ast: AST) => AST;
postprocess: (html: string) => string;
}
export function markdownToHtml(markdown: string, plugins: Plugin[]): string {
for (const plugin of plugins) {
markdown = plugin.preprocess(markdown);
}
let ast = parse(markdown);
for (const plugin of plugins) {
ast = plugin.transform(ast);
}
let html = render(ast);
for (const plugin of plugins) {
html = plugin.postprocess(html);
}
return html;
}
This works — until someone writes a syntax highlighting plugin that needs to fetch a grammar file. That plugin hook needs to be async. Which means the whole function needs to be async. Which means every consumer of your library now deals with promises.
// ❌ Now EVERY user pays the async tax, even when all plugins are sync
export async function markdownToHtml(
markdown: string,
plugins: Plugin[]
): Promise<string> {
for (const plugin of plugins) {
markdown = await plugin.preprocess(markdown);
}
let ast = parse(markdown);
for (const plugin of plugins) {
ast = await plugin.transform(ast);
}
let html = render(ast);
for (const plugin of plugins) {
html = await plugin.postprocess(html);
}
return html;
}
You've maximized flexibility for plugin authors but forced every user to handle async, even when all their plugins are synchronous. The cost of accommodating the possibility that something might be async.
The usual escape? Duplicate the entire pipeline. markdownToHtml and markdownToHtmlSync. Restrict async plugins to the async version only. More code, more inconsistencies, larger bundles.
Is there a better way?
Introducing quansync: the purple function
What if a function could exist in superposition between sync and async — and only collapse to one color when you call it?
That's quansync. Built by sxzz and antfu, inspired by gensync, quansync introduces a third color: purple. A quansync function can be used as either sync or async, and the caller decides which.
The name borrows from quantum mechanics — particles in superposition exist in multiple states simultaneously, and only settle into one when observed.
Instead of maintaining two parallel implementations, you write the logic once. The consumer calls .sync() or .async() depending on their context. The right implementation is selected automatically all the way down the chain.
The wrapper API: bridging existing code
The simplest use case wraps an existing sync/async pair:
import fs from 'node:fs';
import { quansync } from 'quansync';
export const readFile = quansync({
sync: (filepath: string) => fs.readFileSync(filepath, 'utf-8'),
async: (filepath: string) => fs.promises.readFile(filepath, 'utf-8'),
});
// Sync context
const content1 = readFile.sync('package.json');
// Async context
const content2 = await readFile.async('package.json');
// Default: behaves like an async function
const content3 = await readFile('package.json');
The generator API: where the magic happens
The wrapper API is useful, but the real power is composing quansync functions together using generators:
import { quansync } from 'quansync';
export const readFile = quansync({
sync: (filepath: string) => fs.readFileSync(filepath, 'utf-8'),
async: (filepath: string) => fs.promises.readFile(filepath, 'utf-8'),
});
export const readJSON = quansync(function* (filepath: string) {
// yield* calls a quansync function —
// it auto-selects the right implementation at runtime
const content = yield* readFile(filepath);
return JSON.parse(content);
});
// fs.readFileSync used under the hood
const pkg1 = readJSON.sync('package.json');
// fs.promises.readFile used under the hood
const pkg2 = await readJSON.async('package.json');
readJSON never specifies whether it's sync or async. It's purple. The caller decides, and the choice propagates automatically to every quansync dependency.
Build-time macros: if generators feel weird
If function* and yield* syntax makes you uncomfortable, unplugin-quansync lets you write normal async/await and transforms it to generators at build time:
import { quansync } from 'quansync/macro';
// Looks like normal async/await
// Transforms to function* and yield* at build time
export const readJSON = quansync(async (filepath: string) => {
const content = await readFile(filepath);
return JSON.parse(content);
});
export const readJSONSync = readJSON.sync;
Thanks to unplugin, this works in virtually any build tool — unbuild, Vite, Webpack, you name it.
How generators make this possible
Generators are one of JavaScript's most underused features. A function* can yield to pause execution, effectively splitting logic into chunks. The caller controls when the next chunk runs.
Quansync leverages this: at each yield* point, the runner — not the function — decides what happens next. In an async runner, it awaits the promise before resuming. In a sync runner, it grabs the value and continues immediately.
The function itself is colorless. The runner paints it.
Aside: This is the same trick Babel used in the early days of JavaScript to polyfill async/await using generators and yield. The technique predates native async support. Quansync applies it to a different problem — not polyfilling async, but making color optional.
When you should not reach for quansync
I wish most of the time you never need to think about function coloring. High-level applications should support async entry points in most cases, where choosing between sync and async isn't a problem.
But there are real contexts where color is forced — synchronous plugin hooks, build tool pipelines, configuration loaders, CLI tools with sync requirements. In those cases, quansync is a good fit for progressive, gradual adoption.
One tradeoff to know about: Promises in JavaScript are microtasks that delay a tick. yield also introduces overhead — around ~120ns on M1 Max. In performance-critical hot paths, you might want to avoid both async and quansync and stick with direct sync implementations.
The coloring problem, reimagined
Quansync doesn't eliminate the function coloring problem. It introduces a third color — purple — that can collapse to either red (async) or blue (sync) on demand.
Quansync functions still face their own version of the constraint: wrapping a function to support both colors requires it to be a quansync function (or generator). But the key advantage is that purple can be collapsed to either color as needed. Your colorless business logic doesn't get dragged into the red-vs-blue war caused by some leaf operation that happens to touch the filesystem.
The mental model: write your logic in purple. Let it exist in superposition. The moment someone calls
.sync()or.async(), the wave function collapses — and the right implementation runs all the way down.
That's a better world than duplicating every function twice.