1. Why Asynchronous Programming

JavaScript is a single-threaded programming language that can only execute one task at a time. In order to handle different task scheduling logic, asynchronous programming is unavoidable in JavaScript development.

The following scenarios necessarily involve asynchronous programming methods.

  • IO operations: external device access
    • File access
    • TCP / UDP network access
  • Asynchronous APIs
    • setTimeout / setInterval
    • setImmediate
    • process.nextTick
    • queueMicrotask
    • Event

2. Several ways to implement asynchronous programming

  • Callback functions
  • Event Listening / Observer Pattern
  • Promise
  • Async / Await

Functions can be passed as arguments to functions, which is the most basic form of Javascript support for asynchronous programming. Therefore, the callback function pattern is the most widespread and almost the only available means of asynchronous programming in the early days (ES5-). With the introduction of the Promise concept, JavaScript’s modern syntax (ES6+) for asynchronous programming became more and more concise and friendly. Since ES7 introduced Async / Await based on Promise into the syntax standard, asynchronous programming can become as concise and clear as writing synchronous code.

2.1 Callback functions

The ability to pass functions as arguments to functions is the most basic form of Javascript support for asynchronous programming, and this feature makes JavaScript asynchronous programming very flexible and simple. The callback function pattern is the most widespread and almost the only option available for asynchronous programming in the early days (ES5-). However, it is most criticized for having to use a large number of asynchronous callbacks in complex business logic, resulting in a deeply nested callback hell pattern that makes the logic exceptionally complex to read and difficult to maintain.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const fs = require('fs');
 
fs.readFile('./a.txt', (err, data) => {
    if(err) {
        console.log('readFile.error:', err);
    } else {
        fs.readFile('./b.txt', (err1, data1) => {
            if (err1) {
                console.log('readFile.error:', err1);
            } else {
                return data + data1;
            }
        });
    }
});

2.2 Event Listening and Handling

Event listening and handling is a subscriber pattern, and is essentially based on callback functions. It is very common in JavaScript-based development. The subscriber pattern makes it easy to separate less relevant logic implementations and simplify inter-module dependency calls. The event-based subscriber pattern was used extensively in the jQuery era.

However, in large projects with heavy front-end architecture, data dependencies are often extremely complex, and in complex scenarios involving multiple variable factors, the data state of the subscriber pattern is highly uncontrollable and can easily lead to confusion in the subscription dependency logic due to logical fine-tuning of different variable factors. Therefore, there are many libraries of data flow management tools for medium and large front-end projects. Data flow management is still essentially based on the implementation of callback functions.

1
2
3
4
const uid = uuid.v4();
const payload = {...};
workerChannel.postMessage({ uid, payload });
workerChannel.once(uid, (result) => console.log(result));

2.3 Promise

With the introduction of the Promise concept, JavaScript’s modern syntax (ES6+) for asynchronous programming has become more and more concise and friendly. Promise is much better than callback functions and avoids the callback hell paradigm, but its then callback paradigm is not concise. However, Promise provides the foundation for almost all asynchronous-related APIs in the subsequent ES6+ standard.

1
2
3
4
fs.promises.readFile('./a.txt', 'utf8')
  .then(a => fs.promises.readFile('./b.txt', 'utf8').then(b => a + b))
  .then(result => console.log(result));
  .catch (err => {console.log(err));
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function readFile(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, (err, data) => err ? reject(err) : resolve(data));
  });
}
 
readFile('./a.txt')
  .then(a => readFile('./b.txt').then(b => a + b))
  .then(result => console.log(result))
  .catch(err => console.error(err));

2.4 Async / Await

Since ES7(ES2016) introduced Async / Await based on Promise into the syntax standard, asynchronous programming can become as concise and clear as writing synchronous code. ES13(ES2022) also includes Top-level Await in the specification, so that Await can be used anywhere in the ESM module outside the confines of the Async function. Its main features can be summarized as follows.

  • Syntactic sugar: based on Promise base support
  • Asynchronous programming as if you were writing synchronous code
1
2
3
4
5
6
7
8
try {
  const a = await fs.promises.readFile('./a.txt', 'utf8');
  const b = await fs.promises.readFile('./b.txt', 'utf8');
  const result = a + b;
  console.log(result);
} catch (err) {
  console.log(err);
}

3. JavaScript Asynchronous Programming in Practice

  • Promiseization
  • Using Async / Await
  • Concurrent performance: avoiding IO blocking
  • Anti-jitter and throttling

3.1 Delay handling

1
2
3
4
// delay 封装:callback 模式
function delay(timeout = 0, callback: () => void) {
  setTimeout(() => callback(), timeout);
}
1
2
3
4
5
6
7
// delay 封装:Promise 模式
function delay(timeout = 0) {
  return new Promise(resolve => setTimeou(() => resolve(), timeout));
}
 
await delay(3_00).then(() => callback());
callback();
1
2
3
4
5
6
// sleep 封装:TS 类型、回调值支持
export const sleep = <T>(timeout = 0, value?: T | (() => T | Promise<T>)): Promise<T> =>
    new Promise(resolve => setTimeout(() => resolve(), timeout))
      .then(() => typeof value === 'function' ? value() : value);
 
await sleep(3_000, 1);

3.2 Promiseing of event listeners and subscriptions

1
2
3
4
5
6
7
8
9
function request(payload) {
  return new Promise(resolve => {
    const uid = uuid.v4();
    workerChannel.postMessage({ uid, payload });
    workerChannel.once(uid, (result) => resolve(result));
  });
}
 
request({...}).then(body => console.log(body));

Q: How to implement timeout processing?

3.2.1 setTimeout and timeout handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function request(payload, timeout = 5_000) {
  return new Promise(resolve => {
    const uid = uuid.v4();
    const timer = setTimeout(() => resolve({ errmsg: 'timeout' }), timeout);
    workerChannel.once(uid, (result) => {
      clearTimeout(timer);
      resolve(result);
    });
    workerChannel.postMessage({ uid, payload });
  });
}
 
request({...}).then(body => console.log(body));

Q: How to implement a generic package for timeout processing?

3.2.2 Generic encapsulation of timeout handling: raceTimeout

1
2
3
4
5
export function raceTimeout<T>(promise: Promise<T>, timeout: number, onTimeout?: () => T | undefined): Promise<T | undefined> {
    let promiseResolve: ((value: T | undefined) => void) | undefined = undefined;
    const timer = setTimeout(() => promiseResolve?.(onTimeout?.()), timeout);
    return Promise.race([promise.finally(() => clearTimeout(timer)), new Promise<T | undefined>(resolve => (promiseResolve = resolve))]);
}

Call request with raceTimeout to handle the timeout.

1
2
raceTimeout(request({...}), 3_000, () => ({ errmsg: 'timeout' }))
  .then(body => console.log(body));

Use raceTimeout to encapsulate generic timeout handling.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function request(payload, timeout = 5_000) {
  const p = new Promise(resolve => {
    const uid = uuid.v4();
    workerChannel.postMessage({ uid, payload });
    workerChannel.once(uid, (result) => resolve(result));
  });
  return raceTimeout(p, timeout, () => ({ errmsg: 'timeout' }));
}
 
request({...}).then(body => console.log(body));

3.2.3 Generic wrapper for timeout handling: timeoutDeferred

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
interface IScheduledLater extends IDisposable {
    isTriggered(): boolean;
}
function timeoutDeferred(timeout: number, fn: () => void): IScheduledLater {
    let scheduled = true;
    const handle = setTimeout(() => {
        scheduled = false;
        fn();
    }, timeout);
    return {
        isTriggered: () => scheduled,
        dispose: () => {
            clearTimeout(handle);
            scheduled = false;
        },
    };
}

Example of encapsulating generic timeout handling using timeoutDeferred.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function request(payload, timeout = 5_000) {
  const p = new Promise(resolve => {
    const uid = uuid.v4();
    const deferred = timeoutDeferred(timeout, resolve({ errmsg: 'timeout' }));
    workerChannel.postMessage({ uid, payload });
    workerChannel.once(uid, (result) => {
      deferred.dispose();
      resolve(result);
    });
  });
}
 
request({...}).then(body => console.log(body));
  • A simple use, no simpler than using setTimeout directly
  • A more convenient use of timeoutDeferred is to decide how to execute deferred.dispose() in complex logic processes based on different variable factors

3.2.4 Generic wrapper for timeout handling: microtaskDeferred

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function microtaskDeferred(fn: () => void): IScheduledLater {
    let scheduled = true;
    queueMicrotask(() => {
        if (scheduled) {
            scheduled = false;
            fn();
        }
    });
 
    return {
        isTriggered: () => scheduled,
        dispose: () => { scheduled = false; },
    };
};

3.3 Debounce and throttle functions

Take the example of a postal worker delivering a letter.

  • Post office receiving mail - letters = [] ;
  • The postman delivers the letter - function deliver(){}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const letters = [];
/** 邮局接收信件 */
function onLetterReceived(l) {
  letters.push(l);
  deliver(); // 派送策略?
}
/** 邮政员派送信件 */
function deliver() {
  const lettersToDeliver = letters;
  letters = [];
  return makeTheTrip(lettersToDeliver);
}
  • Execute makeTheTrip as soon as the letter is received. Requirement.
    • Receive mail less frequently?
    • Very large number of postal workers?
    • Very fast delivery?
    • More…

3.3.1 lodash : function anti-shake and throttling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { throttle, debounce } from 'lodash';
 
// 节流:100ms 内最多执行一次
const throttler = throttle(deliver, 100);
 
// 防抖:间隔 100ms 以上才触发
const debounced = debounce(deliver, 100);
 
// 防抖:高频调用 - 每隔 1s 至少会触发一次
const debounced = debounce(deliver, 100, { maxWait: 1000 });

Problems.

  • Optimal value: How to determine 100ms and 1000ms?
  • Unable to maximize CPU utilization
  • Can’t handle uncertain task calls with large time consumption better

Why?

  • Main reason: Can’t tell when the message is finished
  • Solution: callback => use back callback hell mode?
  • Solution: try Promise ?

3.3.2 Promise-style antijitter and throttling

  • Throttler : Execute async callback tasks in throttled fashion
1
2
3
4
5
6
7
8
9
export class Throttler {
  /** 正在执行的任务句柄 */
  private activePromise: Promise<unknown> | null;
  /** 等待执行的任务句柄 */
  private queuedPromise: Promise<unknown> | null;
  /** 等待执行的任务 */
  private queuedPromiseFactory: ITask<Promise<unknown>> | null;
  public queue<T>(promiseFactory: ITask<Promise<T>>): Promise<T>;
}

Application examples.

1
2
3
4
5
6
const throttler = new Throttler();
/** 邮局接收信件 */
function onLetterReceived(l) {
  letters.push(l);
  throttler.queue(deliver);
}
  • Delivery strategy
    • Incoming mail initiates delivery tasks
    • Take all emails for each delivery
    • Waiting queue always caches only the latest one - queuedPromiseFactory

3.3.3 Sequencer : sequential execution of async callback tasks

1
2
3
4
5
6
7
8
9
export class Sequencer {
    private current: Promise<unknown> = Promise.resolve(null);
    queue<T>(promiseTask: ITask<Promise<T>>): Promise<T> {
        return (this.current = this.current.then(
            () => promiseTask(),
            () => promiseTask()
        ));
    }
}

Features.

  • One By One
  • Difference with Throttler: no limit on waiting queues ( queue)
  • Simple encapsulation, easy to call

3.3.4 Distinguishing between multiple types of sequential execution of async tasks

Cache different types of Sequencer by key.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export class SequencerByKey<TKey> {
    private promiseMap = new Map<TKey, Promise<unknown>>();
    queue<T>(key: TKey, promiseTask: ITask<Promise<T>>): Promise<T> {
        const runningPromise = this.promiseMap.get(key) ?? Promise.resolve();
        const newPromise = runningPromise
            .catch(() => {})
            .then(promiseTask)
            .finally(() => {
                if (this.promiseMap.get(key) === newPromise) {
                    this.promiseMap.delete(key);
                }
            });
        this.promiseMap.set(key, newPromise);
        return newPromise;
    }
}

3.3.5 Jitter-proof execution of asynchronous tasks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export class Delayer<T> implements IDisposable {
    private deferred: IScheduledLater | null;
    private completionPromise: Promise<unknown> | null;
    private doResolve: ((value?: unknown | Promise<unknown>) => void) | null;
    private doReject: ((err: unknown) => void) | null;
    private task: ITask<T | Promise<T>> | null;
 
    constructor(public defaultDelay: number) {}
    trigger(task: ITask<T | Promise<T>>, delay = this.defaultDelay): Promise<T>;
    isTriggered(): boolean;
    cancel(): void;
}
1
2
3
4
5
6
const delayer = new Delayer(10_000);
const letters = [];
function letterReceived(l) {
  letters.push(l);
  delayer.trigger(() => makeTheTrip());
}

Features.

  • Delayed execution
  • Stateful and cancelable
  • Simple calling method, clear business logic
  • Disadvantages: high-frequency calls keep getting canceled, can’t be called in time

3.3.6 ThrottledDelayer : Anti-jitter + throttling

The postman is smart enough to wait a certain amount of time before going out to deliver the mail (it doesn’t wait all the time).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export class ThrottledDelayer<T> {
    private delayer: Delayer<Promise<T>>;
    private throttler: Throttler;
 
    constructor(defaultDelay: number);
    trigger(promiseFactory: ITask<Promise<T>>, delay?: number): Promise<T> {
        return this.delayer.trigger(() => this.throttler.queue(promiseFactory), delay) as unknown as Promise<T>;
    }
    isTriggered(): boolean { return this.delayer.isTriggered(); }
    cancel(): void { this.delayer.cancel(); }
    dispose(): void { this.delayer.dispose(); }
}
  • Delayed execution of high-frequency tasks: wait for a certain amount of time before sending a message
  • A new delayed task is called in a shake-proof way when the message is delivered: delayer.trigger, delayer.completionPromise
  • When the delivery is complete, the next delivery journey is immediately started: throttler.queuedPromise

3.3.7 Barrier : call barrier before initialization

Creates a barrier with an initial state of closed and a final state of permanently open.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export class Barrier {
    private _isOpen: boolean = false;
    private _promise: Promise<boolean>;
    private _completePromise!: (v: boolean) => void;
    constructor() {
        this._promise = new Promise<boolean>((c, _e) => {
            this._completePromise = c;
        });
    }
    isOpen(): boolean { return this._isOpen }
    open(): void {
        this._isOpen = true;
        this._completePromise(true);
    }
    wait(): Promise<boolean> { return this._promise }
}

Q: How does a postal worker handle a triggered mail delivery task when he or she is not yet on duty?

Example.

1
2
3
4
5
6
7
8
9
const barrier = new Barrier();
async function letterReceived(l) {
  letters.push(l);
  await barrier.wait(); // 等待就绪后调用
  makeTheTrip();
}
 
// ...
barrier.open(); // 邮政员上班了

Advantages.

  • No cumbersome way to create buffers, wait for callbacks, etc.
  • Simplified call chain and clear and concise logic

3.3.8 Timeout auto-open barrier: AutoOpenBarrier

How to automatically enable an alternative (e.g. robotic delivery mode) when the postal worker keeps not coming to work?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export class AutoOpenBarrier extends Barrier {
    private readonly _timeout: NodeJS.Timer;
    constructor(autoOpenTimeMs: number) {
        super();
        this._timeout = setTimeout(() => this.open(), autoOpenTimeMs);
    }
    override open(): void {
        clearTimeout(this._timeout);
        super.open();
    }
}

3.3.9 retry Failure to retry

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
async function retry<T>(task: ITask<Promise<T>>, delay: number, retries: number, validator?: (r: T) => boolean): Promise<T> {
    let lastError: Error | undefined;
 
    for (let i = 0; i < retries; i++) {
        try {
            const result = await task();
            if (!validator || validator(result)) return result;
        } catch (error) {
            lastError = error;
            await sleep(delay);
        }
    }
 
    throw lastError;
}

retry Application example.

1
2
async function doLogin(): { success: boolean } {}
const result = await retry(doLogin, 1_000, 3, r => r.success);