DEV Community

Manoj Kumar Patra
Manoj Kumar Patra

Posted on

Callbacks and Events

# The callback pattern

Continuous-passing style

In JavaScript, a callback is a function that is passed as an argument to another function and is invoked with the result when the operation completes. In functional programming, this way of propagating the result is called continuation-passing style (CPS).

An unpredictable function:


import { readFile } from "fs";
const cache = new Map();
function inconsistentRead(filename, cb) {
  if (cache.has(filename)) {
    // invoked synchronously
    cb(cache.get(filename));
  } else {
    // asynchronous function
    readFile(filename, "utf8", (err, data) => {
      cache.set(filename, data);
      cb(data);
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Using synchronous APIs

Always choose a direct style for purely synchronous functions.

❗ Use blocking APIs sparingly and only when they don't affect the ability of the application to handle concurrent asynchronous operations.


import { readFileSync } from "fs";
const cache = new Map();
function consistentReadSync(filename) {
  if (cache.has(filename)) {
    return cache.get(filename);
  } else {
    const data = readFileSync(filename, "utf8");
    cache.set(filename, data);
    return data;
  }
}

Enter fullscreen mode Exit fullscreen mode

Using synchronous I/O in Node.js is strongly discouraged in many circumstances, but in some situations, this might be the easiest and most efficient solution. Example: It makes perfect sense to use a synchronous blocking API to load a configuration file while bootstrapping an application.

Asynchronous behaviour with deferred execution


import { readFile } from "fs";
const cache = new Map();
function consistentReadAsync(filename, callback) {
  if (cache.has(filename)) {
    // deferred callback invocation
    process.nextTick(() => callback(cache.get(filename)));
  } else {
    // asynchronous function
    readFile(filename, "utf8", (err, data) => {
      cache.set(filename, data);
      callback(data);
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

A callback is invoked asynchronously by deferring its execution using process.nextTick().

process.nextTick() vs setImmediate()

Callbacks deferred with process.nextTick() are called microtasks and they are executed just after the current operation completes, even before any other I/O event is fired.

Callbacks with setImmediate() are queued in an event loop phase that comes after all I/O events have been processed.

Since, process.nextTick() runs before any already scheduled I/O, it will be executed faster, but under certain circumstances, it might also delay the running of any I/O callback indefinitely (also known as I/O starvation), such as in the presence of a recursive invocation. This can never happen with setImmediate().

setImmediate() are executed faster than those scheduled with setTimeout(callback, 0).

NodeJS callback convention

readFile(filename, [options], callback)

callback is of the format (err, data) => { ... }. Here, err must always be of type Error.

Fail-fast approach


process.on("uncaughtException", (err) => {
  console.error(`This will catch at last the JSON parsing exception: ${err.message}`);
  // Terminates the application with 1 (error) as exit code.
  // Without the following line, the application would continue
  process.exit(1);
});

Enter fullscreen mode Exit fullscreen mode

An uncaught exception leaves the application in a state that is not guaranteed to be consistent, which can lead to unforeseeable problems. That's why it is always advised, especially in production, to never leave the application running after an uncaught exception is received. Instead, the process should exit immediately, optionally after having run some necessary cleanup tasks, and ideally, a supervising process should restart the application. This is known as the fail-fast approach.

# The observer pattern

The Observer pattern defines an object (called subject) that can notify a set of observers (or listeners) when a change in its state occurs.

📖 The main difference from the Callback pattern is that the subject can actually notify multiple observers, while a traditional CPS callback will usually propagate its result to only one listener, the callback.


import { EventEmitter } from 'events'
const emitter = new EventEmitter()

on(event, listener): EventEmitter

once(event, listener): EventEmitter

emit(event, [arg1], [...]): EventEmitter

removeListener(event, listener): EventEmitter

Enter fullscreen mode Exit fullscreen mode

Propagating errors

  • The convention is to emit a special event, called error, and pass an Error object as an argument.

Extending EventEmitter class

To make any object observable, have the class extend the EventEmitter class.

  • Examples: Node.js streams, HTTP Module

When the count of listeners registered to an event exceeds a specific amount (by default, 10), the EventEmitter will produce a warning. Sometimes, registering more than 10 listeners is completely fine, so we can adjust this limit by using the setMaxListeners() method of the EventEmitter.

Example:


class FindRegex extends EventEmitter {
   constructor(regex) {
      super();
      this.regex = regex;
      this.files = [];
   }
   addFile(file) {
      this.files.push(file);
      return this;
   }
   find() {
      for (const file of this.files) {
        readFile(file, 'utf8', (err, content) => {
          if (err) {
              return this.emit('error', err);
          }
          this.emit('fileread', file);
          const match = content.match(this.regex);
          if (match) {
              match.forEach(elem => this.emit('found', file, elem));
          }
        });
      }
   }
   return this;
}

Enter fullscreen mode Exit fullscreen mode

Using the above code:


const findRegexInstance = new FindRegex(/hello \w+/);
findRegexInstance
  .addFile('fileA.txt')
  .addFile('fileB.json')
  .find()
  .on('found', (file, match) => console.log(`Matched "${match}" in file ${file}`))
  .on('error', err => console.error(`Error emitted ${err.message}`));

Enter fullscreen mode Exit fullscreen mode

Dealing with event emitters and memory leaks

When subscribing to observables with a long life span, it is extremely important that we unsubscribe our listeners once they are no longer needed to avoid memory leaks.

We can use the convenience method once(event, listener) in place of on(event, listener) to automatically unregister a listener after the event is received for the first time. However, be advised that if the event we specify is never emitted, then the listener is never released, causing a memory leak.

When events are emitted asynchronously, we can register new listeners, even after the task that produces the events is triggered, up until the current stack yields to the event loop. This is because the events are guaranteed not to be fired until the next cycle of the event loop, so we can be sure that we won't miss any events.

# Difference between callbacks and event emitters

The general differentiating rule is semantic: callbacks should be used when a result must be returned in an asynchronous way, while events should be used when there is a need to communicate that something has happened.

# References

Top comments (0)