Simplicity in resource generation using promises

Simplify on deman resource loading with promises.

Take the small "Hello World" Node webserver from nodejs.org home page as a starting point.

1
2
3
4
5
6
var http = require('http');
http.createServer(function onRequest(req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');

Imagine we need to return a text file instead of "Hello World". We can read the file asynchronously to follow the node's way.

1
2
3
4
5
6
var http = require('http');
var fs = require('fs'), hello;
fs.readFile('hello.txt', function (err, data) {
hello = data;
});
// create web server ...

How does the onRequest function know when the hello variable has data? Well, we could check periodically

1
2
3
4
5
6
7
8
9
http.createServer(function onRequest(req, res) {
var i = setInterval(function () {
if (hello) {
clearInterval(i);
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(hello);
}
}, 100);
}).listen(1337, '127.0.0.1');

What if we want to return hello if it is available without setting an interval?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var hello;
http.createServer(function onRequest(req, res) {
function writeHello() {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(hello);
}
if (hello) { return writeHello(); }
var i = setInterval(function () {
if (hello) {
clearInterval(i);
writeHello();
}
}, 100);
}).listen(1337, '127.0.0.1');

What if we want to load a file on the first request for it?

1
2
3
4
5
6
7
8
9
10
11
12
var hello;
http.createServer(function onRequest(req, res) {
function writeHello(txt) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(txt);
}
if (hello) { return writeHello(hello); }
fs.readFile('hello.txt', function (err, data) {
hello = data;
writeHello(hello);
});
}).listen(1337, '127.0.0.1');

Finally, what happens if there is second request for hello while we are loading it? It need to somehow tell other requests to wait until hello is ready. We can use a timer interval again, but we need to know if someone is loading the hello.txt already

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var hello, helloIsLoading;
http.createServer(function onRequest(req, res) {
function writeHello(txt) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(txt);
}
if (hello) { return writeHello(hello); }
if (helloIsLoading) {
var i = setInterval(function () {
if (hello) {
clearInterval(i);
writeHello(hello);
}
}, 100);
} else {
helloIsLoading = true;
fs.readFile('hello.txt', function (err, data) {
hello = data;
helloIsLoading = false;
writeHello(hello);
});
}
}).listen(1337, '127.0.0.1');

We now have 2 problems:

  1. We need to use 2 variables to track the data and the state of the data: hello and helloIsLoading. They are dependent on each other: if helloIsLoading is true, then we assume hello is null, etc. They might get out of sync really quickly.
  2. The loading and waiting logic is already complex, and will only become more complicated as we introduce error handling and multiple resources.

Promises to the rescue

Promises offer an elegant solution to async resoure loading problem. Instead of hello holding the data, it will hold a promise to load data. Promises resolve only once, every call afterwards returns the resolved data. I will use my favorite promise implementation library Q.

1
2
3
4
5
6
7
8
9
10
11
12
var Q = require('q');
var hello;
http.createServer(function onRequest(req, res) {
function writeHello(txt) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(txt);
}
if (!hello) {
hello = Q.nfcall(fs.readFile, 'hello.txt'); // 1
}
Q(hello).then(writeHello); // 2
}).listen(1337, '127.0.0.1');

The very first request will call fs.readFile, creating the promise to read a file and return its content. The promise is stored in hello variable (line // 1). The same first request and every request arriving afterwards will wait while the promise has not been resolved (line // 2). Once the file is read, its content will be the first argument to the writeHello function. The Q() function is a shortcut for Q.when that accepts a value or a promise to that resolves with a value, and is similar to jQuery's $.when() function.

Any request that arrives after that will check the hello promise, see that it has been resolved and will execute writeHello without waiting.

Cache of promises

The above example showed a single resource loaded on demand using promises. Here is a more realistic example with multiple resources loaded on demand. Let us imagine we need to load multiple text files and cache them. This is how our file cache could look without promises

1
2
3
4
var cache = {
'path/to/foo.txt': fs.readSync('path/to/foo.txt', 'utf8'),
...
};

Instead of keeping the file source, we are going to keep promise to load the file.

1
2
3
var cache = {
'path/to/foo.txt': Q.nfcall(fs.readFile, 'path/to/foo.txt', 'utf8')
}

Here is how we are going to do this on demand

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// cache is empty at first
var cache = {};
function readFile(filename) {
if (!check.has(cache, filename)) {
// first request: put promise as a placeholder right away
// whenever it gets resolved, replace promise with a value
// if there are any other people waiting on the promise already
// they will be resolved by the promise itself
cache[filename] = Q.nfcall(fs.readFile, filename, 'utf8').then(function (txt) {
return txt;
});
}
// always returns the promise, even after resolving it
return Q(cache[filename]);
}
module.exports = readFile;

The key to understand this code is to notice that at first load the promise will be placed into the cache. If someone requests same file, the new request will be chained and will be resolved at the same time as first one. Any request after that will get the resolved promise, resolving on the next turn of the event loop.

You can even replace the first promise after resolving it with actual file contents, and the code will still work

1
2
3
4
5
6
7
8
9
10
function readFile(filename) {
if (!check.has(cache, filename)) {
cache[filename] = Q.nfcall(fs.readFile, filename, 'utf8').then(function (txt) {
cache[filename] = txt; // 1
return txt;
});
}
// return a promise at first, and promise resolved with text after // 1
return Q(cache[filename]);
}

I prefer the later code because it allows simpler cache inspection.

Conclusion

The promise offer an elegant and beautiful solution by removing the extra code and variables. You can see this approach in action in my proud-connect source that calculates metrics and generates a badge image on demand, while caching previously generated results.