Server Write Performance


Today I was experimenting with write performance for a web server. I wanted to know what kind of performance I should expect to be able to get when a simple server actually does something beyond a simple “hello world” GET request. Having been inspired by Casey Muratori’s “performance-aware programming” I try to get an understanding of what my baseline performance characteristics should be given the basic tools I’m using (in this case Node.js, fastify, and sqlite) and then run some simple performance tests to validate that I’m not sacrificing more performance than necessary.

I chose to use Node.js 24.3.0, Fastify, and SQLite mostly because they are familiar to myself and my team, but the applied learning of the experiment should not be considered specific to these tools. I’m also using the npm tool autocannon to do simple measuring. The requests/second are not important for this experiment but just the difference from the base case. (NOTE: I’m doing these tests with my machine in power saver mode off the battery, so the numbers will likely be more spread apart if in performance mode). I mostly look at the Avg column when reviewing results.

Let’s start with simple “ok” GET request to establish the expected throughput of this application using the tools given.

import fastify from 'fastify'
const app = fastify({ logger: false })
app.get('/base', () => 'ok')
await app.listen({ port: 3000, host: '0.0.0.0' })
$ autocannon -c 100 http://localhost:3000/base
Running 10s test @ http://localhost:3000/base
100 connections


┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬────────┐
 Stat 2.5% 50% 97.5% 99% Avg Stdev Max
├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼────────┤
 Latency 3 ms 3 ms 7 ms 11 ms 3.62 ms 1.74 ms 118 ms
└─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬──────────┬─────────┐
 Stat 1% 2.5% 50% 97.5% Avg Stdev Min
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼──────────┼─────────┤
 Req/Sec 13,327 13,327 24,511 25,007 23,505.1 3,235.92 13,325
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼──────────┼─────────┤
 Bytes/Sec 2.21 MB 2.21 MB 4.07 MB 4.15 MB 3.9 MB 537 kB 2.21 MB
└───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴──────────┴─────────┘

Req/Bytes counts sampled once per second.
# of samples: 11

259k requests in 11.05s, 42.9 MB read

Average is 23.5k req/sec. So that will be our baseline moving forward.

Let’s now add sqlite and create a table for todos and a route to insert new todos.

import fastify from 'fastify'
+import sqlite from 'node:sqlite'
+const database = new sqlite.DatabaseSync('./db.sqlite')
+database.exec(`CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT, complete INTEGER)`)
const app = fastify({ logger: false })
app.get('/base', () => 'ok')
+app.post('/todos', (request, reply) => {
+  const insert = database.prepare('INSERT INTO todos (item, complete) VALUES (?, ?)')
+  insert.run(request.body.item, 0)
+  reply.status(201).send('ok')
+})
await app.listen({ port: 3000, host: '0.0.0.0' })

And the results are pitiful.

$ autocannon -c 100 --method POST -H 'Content-Type=application/json' --body '{"item":"dishes"}' http://localhost:3000/todos
Running 10s test @ http://localhost:3000/todos
100 connections


┌─────────┬────────┬────────┬────────┬─────────┬───────────┬───────────┬─────────┐
 Stat 2.5% 50% 97.5% 99% Avg Stdev Max
├─────────┼────────┼────────┼────────┼─────────┼───────────┼───────────┼─────────┤
 Latency 103 ms 164 ms 247 ms 2270 ms 225.92 ms 596.69 ms 9745 ms
└─────────┴────────┴────────┴────────┴─────────┴───────────┴───────────┴─────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
 Stat 1% 2.5% 50% 97.5% Avg Stdev Min
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
 Req/Sec 360 360 443 472 437.8 29.64 360
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
 Bytes/Sec 61.6 kB 61.6 kB 75.8 kB 80.8 kB 74.9 kB 5.08 kB 61.6 kB
└───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

Req/Bytes counts sampled once per second.
# of samples: 10

4k requests in 10.07s, 749 kB read

I remembered that for SQLite you need to enable WAL mode if you want to get any decent performance for writes.

$ sqlite3 ./db.sqlite
sqlite> PRAGMA journal_mode=WAL;
wal

That gets me:

$ autocannon -c 100 --method POST -H 'Content-Type=application/json' --body '{"item":"dishes"}' http://localhost:3000/todos
Running 10s test @ http://localhost:3000/todos
100 connections


┌─────────┬───────┬───────┬───────┬────────┬──────────┬─────────┬────────┐
 Stat 2.5% 50% 97.5% 99% Avg Stdev Max
├─────────┼───────┼───────┼───────┼────────┼──────────┼─────────┼────────┤
 Latency 64 ms 73 ms 97 ms 109 ms 75.15 ms 9.94 ms 205 ms
└─────────┴───────┴───────┴───────┴────────┴──────────┴─────────┴────────┘
┌───────────┬────────┬────────┬────────┬────────┬─────────┬─────────┬────────┐
 Stat 1% 2.5% 50% 97.5% Avg Stdev Min
├───────────┼────────┼────────┼────────┼────────┼─────────┼─────────┼────────┤
 Req/Sec 1,057 1,057 1,340 1,440 1,318.8 98.98 1,057
├───────────┼────────┼────────┼────────┼────────┼─────────┼─────────┼────────┤
 Bytes/Sec 181 kB 181 kB 229 kB 246 kB 226 kB 16.9 kB 181 kB
└───────────┴────────┴────────┴────────┴────────┴─────────┴─────────┴────────┘

Req/Bytes counts sampled once per second.
# of samples: 10

13k requests in 10.07s, 2.26 MB read

So that’s definitely better. Went from 437 req/sec average to 1318 req/sec average. About 3x improvement, but that’s still terrible compared to our base case. The next thing I wondered is if I batched the writes by putting everything in a queue and processing in chunks.

import fastify from 'fastify'
import sqlite from 'node:sqlite'
const database = new sqlite.DatabaseSync('./db.sqlite')
database.exec(`CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT, complete INTEGER)`)
const app = fastify({ logger: false })
app.get('/base', () => 'ok')
+
+let queue = []
+function processQueue () {
+  if (queue.length > 0) {
+    let sql = `INSERT INTO todos (item, complete) VALUES ('${queue.shift()}', 0)`
+    for (let i = 0; i < 1000; i++) {
+      if (queue.length === 0) break
+      sql += `, ('${queue.shift()}', 0)`
+    }
+    const insert = database.prepare(sql)
+    insert.run()
+  }
+  setTimeout(processQueue, 1000)
+}
+processQueue()
+
app.post('/todos', (request, reply) => {
-  const insert = database.prepare('INSERT INTO todos (item, complete) VALUES (?, ?)')
-  insert.run(request.body.item, 0)
+  queue.push(request.body.item)
  reply.status(201).send('ok')
})
await app.listen({ port: 3000, host: '0.0.0.0' })
$ autocannon -c 100 --method POST -H 'Content-Type=application/json' --body '{"item":"dishes"}' http://localhost:3000/todos
Running 10s test @ http://localhost:3000/todos
100 connections


┌─────────┬──────┬──────┬───────┬───────┬─────────┬──────────┬────────┐
 Stat 2.5% 50% 97.5% 99% Avg Stdev Max
├─────────┼──────┼──────┼───────┼───────┼─────────┼──────────┼────────┤
 Latency 5 ms 5 ms 9 ms 14 ms 6.52 ms 13.31 ms 612 ms
└─────────┴──────┴──────┴───────┴───────┴─────────┴──────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬──────────┬─────────┐
 Stat 1% 2.5% 50% 97.5% Avg Stdev Min
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼──────────┼─────────┤
 Req/Sec 8,423 8,423 14,927 15,687 14,108 2,059.49 8,417
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼──────────┼─────────┤
 Bytes/Sec 1.44 MB 1.44 MB 2.55 MB 2.68 MB 2.41 MB 352 kB 1.44 MB
└───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴──────────┴─────────┘

Req/Bytes counts sampled once per second.
# of samples: 10

141k requests in 10.06s, 24.1 MB read

Now we’re getting somewhere! Still just a little better than half our base case, but I feel like we’ve got something we can start tuning. Now I’m wondering what would happen if we removed the need to parse JSON on every request and instead passed the value in the query params.

import fastify from 'fastify'
import sqlite from 'node:sqlite'
const database = new sqlite.DatabaseSync('./db.sqlite')
database.exec(`CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT, complete INTEGER)`)
const app = fastify({ logger: false })
app.get('/base', () => 'ok')

let queue = []
function processQueue () {
  if (queue.length > 0) {
    let sql = `INSERT INTO todos (item, complete) VALUES ('${queue.shift()}', 0)`
    for (let i = 0; i < 1000; i++) {
      if (queue.length === 0) break
      sql += `, ('${queue.shift()}', 0)`
    }
    const insert = database.prepare(sql)
    insert.run()
  }
  setTimeout(processQueue, 1000)
}
processQueue()

app.post('/todos', (request, reply) => {
-  queue.push(request.body.item)
+  queue.push(request.query.item)
  reply.status(201).send('ok')
})
await app.listen({ port: 3000, host: '0.0.0.0' })
$ autocannon -c 100 --method POST  http://localhost:3000/todos?item=dishes
Running 10s test @ http://localhost:3000/todos?item=dishes
100 connections


┌─────────┬──────┬──────┬───────┬──────┬─────────┬──────────┬────────┐
 Stat 2.5% 50% 97.5% 99% Avg Stdev Max
├─────────┼──────┼──────┼───────┼──────┼─────────┼──────────┼────────┤
 Latency 3 ms 4 ms 7 ms 9 ms 4.82 ms 12.24 ms 305 ms
└─────────┴──────┴──────┴───────┴──────┴─────────┴──────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬───────────┬──────────┬─────────┐
 Stat 1% 2.5% 50% 97.5% Avg Stdev Min
├───────────┼─────────┼─────────┼─────────┼─────────┼───────────┼──────────┼─────────┤
 Req/Sec 12,399 12,399 19,471 23,343 19,544.41 3,033.68 12,397
├───────────┼─────────┼─────────┼─────────┼─────────┼───────────┼──────────┼─────────┤
 Bytes/Sec 2.12 MB 2.12 MB 3.33 MB 3.99 MB 3.34 MB 519 kB 2.12 MB
└───────────┴─────────┴─────────┴─────────┴─────────┴───────────┴──────────┴─────────┘

Req/Bytes counts sampled once per second.
# of samples: 10

196k requests in 10.06s, 33.4 MB read

Ho ho ho! That’s not too bad! But can we dial the batch processing to get closer?

function processQueue () {
  if (queue.length > 0) {
    let sql = `INSERT INTO todos (item, complete) VALUES ('${queue.shift()}', 0)`
-    for (let i = 0; i < 1000; i++) {
+    for (let i = 0; i < 5000; i++) {
      if (queue.length === 0) break
      sql += `, ('${queue.shift()}', 0)`
    }
    const insert = database.prepare(sql)
    insert.run()
  }
-  setTimeout(processQueue, 1000)
+  setTimeout(processQueue, 50)
}
$ autocannon -c 100 --method POST  http://localhost:3000/todos?item=dishes
Running 10s test @ http://localhost:3000/todos?item=dishes
100 connections


┌─────────┬──────┬──────┬───────┬──────┬─────────┬─────────┬────────┐
 Stat 2.5% 50% 97.5% 99% Avg Stdev Max
├─────────┼──────┼──────┼───────┼──────┼─────────┼─────────┼────────┤
 Latency 3 ms 4 ms 7 ms 8 ms 4.35 ms 4.36 ms 286 ms
└─────────┴──────┴──────┴───────┴──────┴─────────┴─────────┴────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬──────────┬─────────┐
 Stat 1% 2.5% 50% 97.5% Avg Stdev Min
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼──────────┼─────────┤
 Req/Sec 11,791 11,791 22,063 22,335 21,003.6 3,084.98 11,790
├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼──────────┼─────────┤
 Bytes/Sec 2.02 MB 2.02 MB 3.77 MB 3.82 MB 3.59 MB 528 kB 2.02 MB
└───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴──────────┴─────────┘

Req/Bytes counts sampled once per second.
# of samples: 10

210k requests in 10.06s, 35.9 MB read

And man … that is within spittin’ distance of our base case! I’m going to leave it at that and be satisfied for today. There is obviously things that are left out, like doing schema validation/data on the inputs. Better ways to batch the inserts, like considering using a more async approach. But this little experiment showed that if you are not careful you can lose 100x performance real quick, but if you put in just a small bit of effort that you can get most of it back.