Performant data fetching with promises and Eleventy
Fetching a whole bunch of data from APIs at build time can be an intensive process. Getting that data in a performant way and caching it locally is an important part of Jamstack projects.
Don't use await in loops
One of my older post is about fetching data from a GraphQL API with Eleventy. While the example code I wrote works, it does not retrieve data in a very performant way.
The problem is that I use await in a while loop (like a dummy). The direct consequence of this is that the API calls happen sequentially, as brilliantly explained by Jason Lengstorf. Each call has to wait for the previous one to finish instead of running in parallel.
Retrieving all records from an API at build time is a pretty common use case with static site generators and headless CMSes. Generally, such APIs are limiting the number of records you can get in one single query and will make you use pagination.
Here is a rough outline of how to deal with this use case in a performant manner:
- Make a request to the API to retrieve a first batch of data as well as the total number of items to retrieve
- Calculate the number of additional API requests we need to retrieve all data
- If we need to make more calls, store those additional requests as promises
- Use
Promise.all
to execute all additional requests in parallel - Sort our data if needed
- Store all data in a cache file
Using promises and caching
We will use the Axios package and pagination with a limit of 1 on the JSON Placeholder API in this example. That will allow us to make 99 API calls in parallel. We will also use the flat-cache NPM package to store our data for speedier local development.
Now, on with the code! In our Eleventy install, we create a blogposts.js
file in our _data
folder.
// required packages
const path = require("path");
const axios = require("axios");
const flatCache = require("flat-cache");
// Config
const ITEMS_PER_REQUEST = 20;
const CACHE_KEY = "blogposts";
const CACHE_FOLDER = path.resolve("./.cache");
const CACHE_FILE = "blogposts.json";
/**
* Request blogposts
* @param {Int} skipRecords - number or records to skip
* @return {Object} - Total number of items and API data
*/
async function requestPosts(skipRecords = 0) {
try {
const url = `https://jsonplaceholder.typicode.com/posts?_start=${skipRecords}&_limit=${ITEMS_PER_REQUEST}`;
const response = await axios(url, {
method: "get",
headers: { "Accept-Encoding": "gzip,deflate,compress" },
});
// return the total number of items to fetch and the data
return {
total: parseInt(response.headers["x-total-count"], 10),
data: response.data,
};
} catch (err) {
throw new Error(err);
}
}
/**
* Get all posts
* - check if we have a cache
* - if not make api requests and create cache
* @return {Array} - array of API data (from cache if there is one or from API)
*/
async function getAllPosts() {
// load cache
const cache = flatCache.load(CACHE_FILE, CACHE_FOLDER);
const cachedItems = cache.getKey(CACHE_KEY);
// if we have a cache, return cached data
if (cachedItems) {
console.log("Blogposts from cache");
return cachedItems;
}
// if we do not, make queries
console.log("Blogposts from API");
// variables
const requests = [];
const apiData = [];
let additionalRequests = 0;
// make first request and marge results with array
const request = await requestPosts();
apiData.push(...request.data);
// calculate how many additional requests we need
additionalRequests = Math.ceil(request.total / ITEMS_PER_REQUEST) - 1;
// create additional requests
for (let i = 1; i <= additionalRequests; i++) {
const start = i * ITEMS_PER_REQUEST;
const request = requestPosts(start);
requests.push(request);
}
// resolve all additional requests in parallel
const allResponses = await Promise.all(requests);
allResponses.forEach((response) => {
apiData.push(...response.data);
});
// sort data as needed
apiData.sort((a, b) => {
return a.id - b.id;
});
// set and save cache
if (apiData.length) {
cache.setKey(CACHE_KEY, apiData);
cache.save();
}
// return data
return apiData;
}
// export for 11ty
module.exports = getAllPosts();
Data from our API is now available in our templates under the blogposts
key. We can create our list of blogposts and, using pagination
with a size
of 1
, we can also create all our blogposts detail pages.
By having requests running in parallel, we fetch our data in a performant manner. When comparing this approach to the sequential one used in my previous blogpost with the same API, performance is eight to ten times faster.
Storing that data in a static cache allows us to not constantly hit the API during development. I usually delete that cache as part of my build process. That way, I am sure that to get fresh data from the API or from the headless CMS every time the site is built.
If you need your caching to be more versatile and configurable, check out the eleventy-cache-assets
plugin by Zach himself.