Beyond the Lag: How Whats On Invers Went from Crawling to Soaring – A Case Study in Web Performance
Project Overview
Mike contacted me early in 2021 about his website Whats On Invers not working. More specifically, it would topple over when more than 15 users were on it at once which was highly problematic given the site’s need to handle traffic surges, especially during breaking news events when over 500 people would attempt to access it simultaneously. The previous local developers had left him with a disorganized and inefficient codebase, to put it mildly.
The website needed an urgent resuscitation, or his business would take a serious hit. A functional online presence is vital to a local news media website.
Plan
Whats on Invers was built on Wordpress and at that point needed to at least remain on Wordpress as migrating to another platform would have been overly complex with too many associated risks. The optimal solution for the website was to maintain Wordpress as the back-end content management system (CMS) and leverage the Wordpress REST API to develop a much more efficient front-end which could handle the traffic.
After some research I decided I would use the Frontity framework as the front-end. Frontity was a good choice as it allowed me to build a React-based frontend for a headless (or decoupled) Wordpress site.
Objectives
- Significantly decrease the number of installed plugins without causing any disruptions to the website’s functionality.
- Develop a high-performance, mobile-first front-end capable of handling 1000+ concurrent users.
- Preserve the core advertising functionality from the original website, this includes specific rules about ad placement within content.
- Handle sponsors for taxonomies, posts. Also the requirement that a different category of adverts be served to the funeral notices posts.
Creating a functional performant front-end with all the required specifications was a big task complicated by the fact that not all the data was served by the Wordpress REST API. I had to get my hands dirty and build a custom wordpress plugin which would expose this data.
Challenges
Below is a by no means comprehensive list of challenges faced in the implementation of the website.
1. Dynamically loading adverts within content
- The challenge here is the fact that a performant news website needs to cache as much content as possible. The old wordpress implementation would build the webpage on the server and then send that webpage to the user as the response, and this included with adverts and any other content. How could we replicate this behaviour in a headless fashion?
- Solution: inject placeholders into raw content based on the number of paragraphs.
function inject_placeholders_into_content($post_content, $post_categories) {
$paragraphs = explode('</p>', $post_content);
$paragraph_count = count($paragraphs);
// Determine index based on paragraph count
if ($paragraph_count >= 15) {
$indexes = [4, 15];
} elseif ($paragraph_count >= 10) {
$indexes = [3, 10];
} else {
$indexes = [2, 6];
}
// Predefined insertion points
$insertionPoints = [2, 6, 10, 15];
for ($i = 0; $i < $indexes[0]; $i++) {
$insert_after = $insertionPoints[$i] ?? $insert_after; // Use the last value if the index is out of bounds
$pos = $i + 1;
$insertion = "<div class='g g-1'><div class='g-single a-slot-" . $pos . "'><div class='text-center advertise'><small class='advertise-text color-silver-light'>– <a href='/promote/' rel'noopener' target='_blank'>Advertise on whatsoninvers.nz</a> –</small></div><div class='a-" . $pos . "'></div></div></div>";
array_splice($paragraphs, $insert_after, 0, $insertion);
}
$post_content = implode('', $paragraphs);
return [$post_content, $indexes[0]];
}
This function was used within the context of another function as defined below:
function serve_posts($request)
{
$input_params = $request->get_params();
$request = new WP_REST_Request('GET', '/wp/v2/posts');
// Define a list of parameters that should be cast to int
$intParams = ['categories', 'id', 'author', 'page', 'tags', 'per_page'];
foreach ($input_params as $key => $value) {
if (in_array($key, $intParams)) {
$request->set_param($key, (int)$value);
} elseif ($key === 'slug') {
$request->set_param($key, $value);
}
}
// Set default value for per_page if not provided
if (!isset($input_params['per_page'])) {
$request->set_param('per_page', 24);
}
$request->set_headers([
'Content-Type' => 'application/json',
'Cache-Control' => 'max-age=900, stale-while-revalidate=600, s-maxage=1800',
'access-control-allow-headers' => 'X-WP-Total, X-WP-TotalPages',
'access-control-expose-headers' => 'X-WP-Total, X-WP-TotalPages, Link'
]);
$response = rest_do_request($request);
$posts = rest_get_server()->response_to_data($response, true);
$data = [];
$i = 0;
foreach ($posts as $post) {
$obj = inject_placeholders_into_content($post['content']['rendered'], $post['categories']);
$post['content']['rendered'] = $obj[0];
$post['num_ads'] = $obj[1];
$data[$i] = $post;
$i++;
}
$client_response = new WP_REST_Response($data);
$client_response->header('X-WP-Total', $response->get_headers()['X-WP-Total']);
$client_response->header('X-WP-TotalPages', $response->get_headers()['X-WP-TotalPages']);
$client_response->set_status(200);
return $client_response;
}
The main point here is that Wordpress would internally call the /wp/v2/posts
endpoint (with any required parameters) while also attempting to cache that response to reduce server load.
We are able to then inject placeholders within the content and then serve them to the front-end, where the front-end will scan the content for these placeholders and replace them with the required adverts.
What’s important to note is this:
$response = rest_do_request($request);
$posts = rest_get_server()->response_to_data($response, true);
Additionally, response_to_data()
allows us to append the _embedded
field to each post object which contains the featured image for each post, as well as other key information required by the front end. By default
the endpoint wp/v2/posts
does not include this information.
With all that being said, it is important to use the add_action
function to register the rest route so we are able to access this route from our client.
add_action('rest_api_init', function () {
register_rest_route('wp/v2', 'posts_filter', [
'methods' => 'GET',
'callback' => 'serve_posts',
'args' => [
'id',
'category',
'slug',
'author',
'page',
'per_page',
'tags'
],
'permission_callback' => '__return_true'
]);
}
2. Dynamically replacing the placeholders on the front-end
- With the above implementation we have placeholders within the content where we’d like the adverts to be, however the question remains how do we actually inject these adverts dynamically into the content?
- Solution:
useEffect(() => {
const getPostBlockAds = async () => {
let ads = await getBlockAds(libraries.source.api);
const funeralAds = ads.filter((ad => ad.funeral == true));
ads = formatBlockAds(ads);
let blocks = new Array();
// inject into content.
for (let i = 0; i < post.numAds; i++) {
blocks[i] = ads[i];
let injectContent;
if (ads[i].isIframe) {
injectContent = `<iframe src="${ads[i].src}" style="border:none overflow:hidden" width="${ads[i].width}" height="${ads[i].height}" frameborder=0>`;
} else {
injectContent = `<a href="${ads[i].href}"><img src="${ads[i].src}" /></a>`;
}
let searchString = `<div class='a-${i+1}'></div>`;
tempPostContent = tempPostContent.replace(searchString, injectContent);
}
if (isMounted) setPostContent(tempPostContent);
}
}, [post.id]);
The above snippet utilizes the useEffect
React Hook which allows it to execute the getPostBlockAds()
function after the component has rendered thus minimizing load time.
The significance of useEffect
in this context is to ensure that the code inside it is executed after the component is rendered and whenever the post.id
dependency changes.
This ensures that the ad fetching and injection process occurs when the post.id
changes, indicating a new post has been loaded or the post content has been updated.
A loop within getPostBlockAds()
first fetches the adverts (via a custom Wordpress REST API endpoint /wp/v2/serve_ads
), randomly shuffles the adverts via formatBlockAds()
, then iterates over the ads and injects
them into the content of the post, and depending on whether the ad is an iframe or an image, appropriate HTML content is
generated for injection. Finally if the component is still mounted via isMounted
, the post content is updated with the injected ads using setPostContent
, which is defined using React.useState([])
hook.
How postContent
is used:
<Content
as={Section}
px={{ base: "0", md: "0" }}
size="md"
pt={{base: "25px", md: "30px"}}
color={mode('white', 'gray.600')}
>
<Html2React html={postContent} />
{sponsor.news_sponsor_word && (
<Section p="20px" mt="20px" key={post.id} bg={mode('gray.100', 'gray.300')}>
<Heading size="md" fontWeight="black" fontSize='xl' as="h3" pb="10px">A message from our sponsor</Heading>
<Text>{sponsor.message}</Text>
<Heading size="md" fontWeight="bold" as="h4" pb="10px" pt="10px">Contact</Heading>
<Text>Phone: {sponsor.telephone}</Text>
<Text>Website: <Link link={sponsor.website} color="blue">{sponsor.website}</Link></Text>
</Section>
)}
</Content>
Technology Stack
- Frontend: Frontity, React.js, Chakra UI, Emotion CSS styling.
- Backend: Wordpress CMS leveraging the Wordpress REST API.
The front-end client is deployed on Vercel.
Outcome
Not only has the website’s performance seen a significant boost, but its appearance, especially on mobile devices, has also undergone notable enhancement.
Going from an F grade at a 25% performance score to an A grade with a 99% performance score is an enormous jump.