This post is a prequel to a series of in-depth reviews of state management libraries for React. Catch new posts in the series by signing up for the monthly posts.

In the last monthly post, I mentioned that I will be doing in-depth reviews of state management libraries for the next few months. To make these reviews thorough, I planned to refactor a real web app each month to use a different library.

However, the real projects I am working on are too big for me to refactor in a month. So I had to create a new web app from scratch to use for this series of reviews. To make it as close to a real web app as possible, I also setup a live REST API for the app to use. In this post, I will share how I built this sample web app and some of the surprising things I learnt along the way.

TLDR; You can find the code for the sample app here and see it running here. I will be refactoring it to use different state management libraries in the coming months to compare the performance and developer experience of each.

Hand drawn mockup of an app screen with the word sample watermarked

What is the quintessential web app?

Web apps come in all shapes and sizes. How could a single app represent all of them?

Let's See Who This Really Is Meme showing web apps under the mask of all kinds of apps
OK gang, let's see what this app really is

I decided to think in terms of features. Generally, most web apps have the following features.

So the sample app had to have at least those features. After pondering several options, I settled on building a small time-tracking app that would let users track the amount of time they spent on different tasks. Why a time-tracking app? Because I have a strange obsession with them and have built several in the past.

Building a time-tracking app

Now I had to decide how to build this time tracking app.


The web app had to be a React SPA (Single Page Application) since the theme of the series is state management libraries. So I bootstrapped a React SPA project using Vite.

Since we will be comparing several libraries, I decided not to use any state management libraries for the initial app and only use React Context and React hooks. This way we can also compare each library against an app with no state management libraries.

I ended up using the following packages to make my life a bit easier


For the backend, I decided to use PHP-CRUD-API. It is a single PHP script that provides a REST API for a MySQL database. I was already paying for some PHP web hosting to run my blogs so using PHP meant that I would not have to pay extra to host the backend.

Meet Timo

Real web apps have names. So let's call the sample app Timo.

It has three screens, one for logging in and signing up, another for viewing and changing time entries and finally, a screen with a timer for creating new time entries. You can try it out yourself here.

Screenshot of Timo's login screen
Login screen

Screenshot of Timo's entries screen
Entries screen

Screenshot of Timo's timer screen
Timer screen

Measurements and benchmarks

Lines of code

I split the app into two projects, @timo/react and @timo/common so that future implementations can be in separate projects and share code through the @timo/common project.

Here is what cloc has to say about the lines of code in these projects.

Language files blank comment code
JSX 5 41 1 430
CSS 4 16 0 96
JSON 1 0 0 22
JavaScript 1 1 1 20
HTML 1 0 0 13
SUM: 12 58 2 581
Language files blank comment code
JSX 12 65 1 311
CSS 10 14 1 110
JavaScript 11 10 1 81
JSON 1 0 0 19
SUM: 34 89 3 521
packages/react + packages/common
Language files blank comment code
JSX 17 106 2 741
CSS 14 30 1 206
JavaScript 12 11 2 101
JSON 2 0 0 41
HTML 1 0 0 13
SUM: 46 147 5 1102

These numbers don't mean much on their own. So here is the cloc output from a real React web app in production. It was created in 2018 with Create React App and has 21 routes with relatively simple content. It has a mixture of class and functional components and is in the middle of a migration from Redux + Redux Saga to Redux toolkit. Let's call this app the Real Deal.

Language files blank comment code
JavaScript 187 1034 131 7682
SCSS 76 577 48 3522
SVG 84 3 15 1577
JSON 1 33 0 287
SUM: 348 1647 194 13068

I consider the Real Deal to be a medium size web app based on my experience. It has roughly 9x more Javascript code than Timo which means nothing at this point but maybe we can multiply changes to the line count when Timo is refactored to get an idea of what the change might be in a real app.

Build artifacts

Here are the sizes of the files in a production build of Timo.

$ npm run build -w @timo/react

> @timo/react@0.0.1 build
> vite build

vite v5.1.6 building for production...
✓ 5312 modules transformed.
dist/index.html 0.47 kB │ gzip: 0.30 kB
dist/assets/index-C60zlibj.css 5.53 kB │ gzip: 1.84 kB
dist/assets/index-DGyaFOHP.js 172.79 kB │ gzip: 55.52 kB
✓ built in 4.04s

For a rough comparison, here are the files in a production build of the Real Deal.

$ npm run build              

> real-deal@1.10.1 build
> NODE_ENV=production react-scripts build

Creating an optimized production build...
Compiled successfully.

File sizes after gzip:

273.16 kB build/static/js/main.37b34c2e.js
33.91 kB build/static/css/main.a9d9feca.css

The project was built assuming it is hosted at /.
You can control this with the homepage field in your package.json.

The build folder is ready to be deployed.
You may serve it with a static server:

yarn global add serve
serve -s build

Find out more about deployment here:

CRA is a bit deceiving here since it only shows the gzipped file sizes. Here are the sizes of the files when uncompressed.

$ ls -lh -R build/static
total 0
drwxr-xr-x@ 3 bash bash 96B Apr 5 14:11 css
drwxr-xr-x@ 4 bash bash 128B Apr 5 14:11 js

total 224
-rw-r--r--@ 1 bash bash 109K Apr 5 14:11 main.a9d9feca.css

total 1784
-rw-r--r--@ 1 bash bash 886K Apr 5 14:11 main.37b34c2e.js
-rw-r--r--@ 1 bash bash 1.8K Apr 5 14:11 main.37b34c2e.js.LICENSE.txt

Real deal has a Javascript bundle that is ~5x larger and a CSS bundle that is ~20x larger than Timo.

Bundle analysis

If we dive into the Javascript bundle, this is what we find. You can find the interactive version here.

Screenshot of the output from rollup-plugin-visualizer
Breakdown of the Javascript bundle by rollup-plugin-visualizer

Here is a breakdown in percentages.

and a percentage breakdown of node_modules.

I couldn't find a plugin similar to rollup-plugin-visualizer for analyzing the CSS bundle. So I analyzed it manually.


This is what the Chrome dev tools profiler says about Timo when it is run on my 2019 MacBook Pro.

Screenshot of the Chrome dev tools profiler loading the Entries page
Loading the entries page (FCP: 325.70ms)

Here is what it looks like with 4x CPU throttling and a throttled network (Fast 3G).

Screenshot of the Chrome dev tools profiler loading the Entries page with throttling
Loading the entries page (throttled) (FCP: 1.75s)

Google's Lighthouse considers a First Contentful Paint (FCP) score of less than 1.8s to be fast. Even though Timo is small, it only narrowly manages to be within that threshold when throttled.

In both cases, loading the Javascript bundle is the longest part of the load process. This is probably because Timo is hosted on shared web hosting on Hetzner with no CDN.


Timo currently has the following technical issues. Let me know if you find any more!

API requests are not cached on the frontend

No data is cached on the frontend so a request is made to the API every time the list of time entries is shown.

Requests to the API are duplicated when running in development mode

The app needs to make requests to the API when a screen is loaded to check the authorization status and to download the time entries that will be shown. The only way to achieve this right now is to place the request to the API in a useEffect hook. useEffect hooks are fired twice when running locally in development mode when React Strict Mode is enabled.


Here are a few things I learnt/discovered while building Timo.

The native History API is tricky to work with

I had to implement the routing myself since I decided not to use React router. I thought that implementing a simple router on top of the native History API would be quite straightforward. But the native History API does not provide an event to listen to route changes. So I would have had to implement a way to track when the route was changed myself. Instead, I looked at how React router solved this and found that it used the relatively small history package to deal with the History API. So I did the same.

Third-party cookies are dead

Last year I built an early prototype of a web app with the web app living on one subdomain and the API on another using cookies for authorization. This didn't work on Safari if the two were on two separate root domains but it worked when they were on two subdomains of the same root domain. Chrome didn't care and sent the auth cookies to the API in both cases. So I initially planned Timo's web app to be hosted on GitHub pages at and the API to be hosted on Hetzner at

But it didn't work. Now Chrome was also not sending the auth cookies to the API when the web app was making requests from its domain. So I had to stop using cookies for authorization or host the web app and the API on the same domain. I chose the latter option to keep things simple. Now, everything is hosted on Hetzner web hosting.

Vite and Create React App have built-in proxy servers

With the stricter cookie policy, it wasn't possible for an app running on localhost to send auth cookies to an API hosted at While trying to fix this, I found that both Create React App and Vite have built-in proxies to proxy requests from localhost to whatever domain. (Proxying API Requests in Development | Create React App, Server Options | Vite)

CDNs make a sizeable difference

Out of curiosity, I also profiled the Real Deal on production. Embarrassingly, I found that the build artifacts were not being served compressed. All of the HTML, CSS and Javascript were being sent over the network uncompressed. But it still has a load time that was similar to Timo while being several times larger. How could this be?

The answer was a CDN (Content Delivery Network). Real Deal was hosted on a AWS Cloudfront and it managed to repeatedly send the uncompressed Javascript bundle (886KB, 16X) faster than Timo's compressed Javascript bundle (55KB, 1X). Timo was hosted in the same region where I live (eu-central) so Cloudfront must have a PoP that was even closer to me.

Coming up next

With the sample app done, we can now start diving into different state management libraries.

In the next monthly post, I will introduce you to the libraries that we will be taking a look at and how each of them could be categorized. You can sign up below to get the monthly posts directly to your e-mail.

Prabashwara Seneviratne

Written by

Prabashwara Seneviratne

Author. Lead frontend developer.