Minimal server-side rendering with React, part 3
Published by Simon Ingeson
This is part of a series:
This blog post is a follow-up to my previous one. This time we’ll add a build step using @babel/cli
and do some basic benchmarking to see what we gain in performance. If you haven’t followed along, feel free to clone the current version and checkout tag part-2
.
Notes on benchmarking using ApacheBench
While there are many options to benchmark an HTTP server, we’ll stick with ApacheBench. If you’re running macOS, you most likely have some version of ab
(the ApacheBench command-line interface) installed as it ships with Apache HTTP Server.
While there are some caveats with using this tool (see “How to use ApacheBench […]” in the Datadog blog), in our use case, we’ll only compare results before and after a code change on the same machine and with the same configuration. We want to compare apples to (newer) apples. Therefore, I think it’s acceptable to run the ab
command on the same machine as the server.
Getting a baseline performance
First, we need to run the server in the most optimal way it’s currently capable of running. If you’re familiar with express
, you know we need to at minimum set the NODE_ENV
environment variable. On macOS and *nix systems, you can do so like this:
NODE_ENV=production npm start
Feel free to try it both with and without the
NODE_ENV
variable set, and you should notice quite a difference in the number of requests per second metric (on my machine, it added almost 2,500 requests per second).
With the server running, in a second terminal, run the ab
command, adjusting the port according to your setup:
ab -k -n 10000 -c 200 http://localhost:3000/
On my machine, I typically get these results:
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
Benchmarking localhost (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: localhost
Server Port: 3000
Document Path: /
Document Length: 334 bytes
Concurrency Level: 200
Time taken for tests: 1.670 seconds
Complete requests: 10000
Failed requests: 0
Keep-Alive requests: 10000
Total transferred: 5410000 bytes
HTML transferred: 3340000 bytes
Requests per second: 5987.35 [#/sec] (mean)
Time per request: 33.404 [ms] (mean)
Time per request: 0.167 [ms] (mean, across all concurrent requests)
Transfer rate: 3163.24 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.7 0 7
Processing: 4 33 4.7 32 57
Waiting: 1 33 4.7 32 57
Total: 4 33 4.6 32 57
Percentage of the requests served within a certain time (ms)
50% 32
66% 33
75% 33
80% 34
90% 36
95% 44
98% 49
99% 51
100% 57 (longest request)
You may need to tweak the values for the -n
and -c
flags until you get reasonably consistent results. But for the sake of argument, we’re getting about 6,000 requests per second while relying on @babel/register
. I have seen as low as 4,500 requests per second, so it varies quite a bit.
Rearrange the source code
Since we want to build all of our source code, it will be easier to keep it all in a single folder. So before we move forward, we’ll rearrange all of our files to an src
folder. The new folder structure should match this:
src/
components/
app.js
layout.js
lib/
render-app.js
pages/
404.js
about.js
index.js
index.js
.babelrc
.gitignore
package.json
While the import paths should remain the same, make sure all of the import paths are correct after this step.
Install dependencies
To build our project we could use webpack
, rollup
, or pretty much anything that integrates with Babel. For now, we’ll stick with the simple route and use @babel/cli
. We’ll also want to move the @babel/*
dependencies over to our development dependencies:
npm install --save-dev \
@babel/cli \
@babel/core \
@babel/preset-env \
@babel/preset-react \
@babel/register
Configuring build script
With the *.js
files moved and @babel/cli
installed, it’s time to update the scripts in the package.json
file:
"scripts": {
+ "build": "babel src --out-dir dist",
- "dev": "nodemon --require @babel/register index.js",
+ "dev": "nodemon --require @babel/register src/index.js",
- "start": "node --require @babel/register index.js"
+ "start": "NODE_ENV=production node dist/index.js"
},
Update .babelrc
to target Node.js
By default, @babel/preset-env
compiles all code to ES5. However, Babel recommends to have a specific targets
value set. So to fix that, we’ll also update the .babelrc
file:
{
"presets": [
- "@babel/preset-env",
+ ["@babel/preset-env", { "targets": { "node": "current" } }],
["@babel/preset-react", { "runtime": "automatic" }]
]
}
Build, start the server, and run ab
again
If you haven’t already, stop the running server and go ahead and build and run it:
npm run build
npm start
Then in a separate terminal, run ab
with the same configuration as before:
ab -k -n 10000 -c 200 http://localhost:3000/
And this is the result on my machine:
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/
Benchmarking localhost (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: localhost
Server Port: 5000
Document Path: /
Document Length: 334 bytes
Concurrency Level: 200
Time taken for tests: 1.598 seconds
Complete requests: 10000
Failed requests: 0
Keep-Alive requests: 10000
Total transferred: 5410000 bytes
HTML transferred: 3340000 bytes
Requests per second: 6257.38 [#/sec] (mean)
Time per request: 31.962 [ms] (mean)
Time per request: 0.160 [ms] (mean, across all concurrent requests)
Transfer rate: 3305.90 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.7 0 7
Processing: 2 31 3.9 31 53
Waiting: 1 31 3.9 31 53
Total: 2 32 3.7 31 53
Percentage of the requests served within a certain time (ms)
50% 31
66% 32
75% 32
80% 32
90% 33
95% 37
98% 45
99% 48
100% 53 (longest request)
While it wasn’t a significant boost, it did add about 200 requests per second when I average my benchmark runs. The mean request time was also slightly improved from 33 ms to 31 ms (faster is better).
In the end, the question is: was it worth it since the increase was so minimal? I’d argue yes, it was. Not necessarily due to the performance, but because we’re no longer relying on @babel/register
as a runtime dependency and can avoid any known (or unknown) security issues. There are also some other caveats using @babel/register
(or @babel/node
for that matter). By not using them in production, we avoid all of that.
Now that we can build our server into a production-friendlier version, there is some housekeeping we should cover:
- We have zero tests to make sure our code does what we think it does. Even though our code is straightforward, testing our code will improve our confidence in it. It will also make code changes less error-prone when we have tests to verify we didn’t break anything.
- There is some
express
specific configuration we should add to follow best practices. We can also improve our performance by caching the rendered HTML. - Our pages all use the same
<title/>
. Not ideal for SEO, and a big reason we’re doing server-side rendering. - What about CSS, client-side JavaScript, and other static files? While the current setup is functional, adding these files can vastly improve the user experience.
While this blog post concludes the minimal setup series, I plan to cover all of these topics in upcoming blog posts. Stay tuned! Until then, check the current state of the code over on GitHub.
Cover photo by Yancy Min.