0 cookiesnothing loads before a clickunlimited teammatesreply from slack or discord<4kb launcherno per-seat tax

/blog / receipts

1000 messages a second on one core

· by kuba · 9 min read

The Spookat cat, startled, on top of the highest of six rising steps, with 1000/s written beside them.

On 2026-09-26 we load-tested Spookat on the box it runs on in production. One process on one core took 1000 visitor messages a second with a p99 of 191 ms and zero errors, and 800 a second while also posting every message to a Slack mock. This post is the method, every stair of the test, the three fixes that got us there and the things we didn’t measure.

why we bothered

Spookat is a small app. One Bun process, one SQLite file, a box we share with other projects. That setup is cheap and easy to reason about, and the obvious question about it is how far it goes before it falls over.

The first answer we had was a guess. The widget had a hard cap of 500 visitor messages per site per day, set early as a brake against spam, and our site said chats were unlimited. Both can’t be true, so we wanted a real number before choosing which one to change.

We also wanted to know what saturates first. If it’s the database, the fix looks one way. If it’s the job runner that posts to Slack, it looks completely different.

the setup

Everything ran on the production box, next to the live app but never touching it:

  • Binary: the same release build that serves real traffic, started as a separate process on its own port.
  • Database: a fresh SQLite file in a test directory, seeded with 1200 test sites. The production database and its Litestream replica were never opened.
  • Network: load came over loopback. Cloudflare and nginx were not in the path, so these numbers are the app alone.
  • Outside services: the test process had no Slack, Discord or Paddle keys, so nothing could leave the box. For the runs “with Slack” we preloaded a small mock into the binary (BUN_OPTIONS=--preload) that answers every Slack post after about 50 ms.
  • Load: a generator opened widget websockets and sent visitor messages at a fixed rate in stairs of 60 seconds each. Each message is a real widget frame, so it goes through the same code as a visitor typing: rate limits, the flood brake, saveMessage(), the job queue, the Slack post, the ack back to the widget.
  • What we read: p99 of the ack (time from the widget sending a message to the server confirming it was saved), CPU of the serve process, event loop lag, errors, RAM, and for Slack runs, how many messages actually reached the mock.

The box has 8 cores. The serve process uses one of them, because Bun runs your JavaScript on one thread. The generator used 4–7% of another core, so it was never the bottleneck.

round one: the stairs before any fixes

target rate saved p99 ack CPU (1 core)
50/s 50/s 6 ms 15%
100/s 92/s 5.5 ms 25%
200/s 146/s 6 ms 36%
400/s 273/s 482 ms 60%

Two things in that table need a comment.

The “saved” column is below the target from 100/s up. That was our generator’s fault, not the server’s: it reused client ids, and the server correctly dropped those messages as duplicates. We fixed the generator before round two.

The jump at 400/s is the real finding. The CPU sat at 60%, so the core wasn’t full, yet p99 went from 6 ms to 482 ms and the event loop lagged over 100 ms thirteen times. Something was blocking.

RAM stayed around 110 MB the whole time. Each message added about 0.5 kB to the database.

the three fixes

We profiled the process under load. Three things stood out.

1. an fsync on every commit

SQLite in WAL mode with the default synchronous = FULL waits for the disk after every commit. Bun’s SQLite is synchronous, so while it waits, nothing else in the process runs. At 400 messages a second that’s 400 disk waits a second on the one thread that also answers every websocket.

We switched to synchronous = NORMAL, which is what Litestream recommends for WAL. The tradeoff, in plain words: if the process crashes, nothing is lost. If the whole machine loses power, the last few commits before the cut can be lost. We took that trade and wrote it next to the setting in the code.

2. the same SQL, compiled again and again

Drizzle, our query builder, prepared every statement from scratch on every call. Compiling identical SQL over and over was about 16% of the CPU a widget message cost. We now keep prepared statements in a cache (up to 1000, oldest out first) and reuse them. Later we went further and prepared the hottest queries on the widget-to-Slack path once, by hand, which took off roughly another 20% of CPU.

3. a webhook payload nobody asked for

Every saved message built its webhook payload, even on sites with no webhook. Now the payload is built only when there’s a delivery to record.

Locally, at 300 messages a second, the write transaction went from 0.95 ms to 0.52 ms and p99 from 41 ms to 7 ms. At 1200 a second on a laptop, p99 was 8.6 ms with no errors.

round two: the stairs after the fixes

Same box, same setup, the release with the fixes:

rate p99 ack CPU (1 core) errors
200/s 8 ms 38% 0
400/s 11 ms 56% 0
600/s 42 ms 76% 0
800/s 3.9 s 92% 0

400 a second went from 482 ms to 11 ms. At 800 the core was full: messages queued and waited, but not one was lost.

We also ran a flood test in this round, because the old daily cap was gone and a per-site brake had replaced it. For 90 seconds one site got 5 new visitors a second while the other sites carried 200 messages a second. The flooded site let 60 new chats through, then the brake stepped up a level, and a minute later another. It rejected 87% of the flood (389 of 449 messages). The other sites saw p99 8.6 ms and zero errors, the same as without the flood.

the number was wrong

600 a second looked great. It was also too optimistic, and we caught it before writing it anywhere.

None of the test sites had a Slack or Discord channel connected. For a real customer, every visitor message also becomes a job: the runner claims it, posts it to Slack, and marks it done. All of that runs on the same core as the widget. So we pointed the test sites at the Slack mock and ran it again.

At 200 messages a second with Slack, the core was at 96% and only 8129 of 11,998 messages reached the mock during the run. The real ceiling for a site with Slack connected was under 200 a second.

the fourth and fifth fixes

The profile under Slack load was clear. About 70% of the CPU went to the job runner looking for work.

  • After every new job and every finished job, the runner scanned the queues of every lane (chat posts, webhooks, email, maintenance) to see what it could start. Most lanes were empty almost all the time, so almost all of that scanning found nothing. Now it only claims from a lane that can have work.
  • The chat lane had 8 slots, which capped all Slack and Discord posts on the box at about 140 a second, whatever the CPU. It now has 64. Per-channel order and Slack’s 429s are handled in the queue itself, so more slots don’t mean posts out of order.

With the runner fix alone, 200 a second with Slack dropped from 96% to 63% of the core, and every message arrived. The prepared statements on the Slack path came on top of that.

round three: 1000 a second

The final build, on the box, with and without the Slack mock:

before the fixes after
no Slack, 800/s p99 3.9 s, core full p99 12 ms, 58% of the core
no Slack, 1000/s not reached p99 191 ms, 69%, 0 errors
no Slack, 1200/s not reached p99 290 ms, 79%: the edge
Slack, 200/s 96% of the core, 8129 of 11,998 delivered 63%, all delivered
Slack, 800/s not reached p99 75 ms, 86%, all delivered
Slack, 1000/s not reached saturated, p99 408 ms, the backlog cleared itself in about a minute

That’s one process on one core taking 1000 visitor messages a second without Slack, and 800 a second with every message posted to Slack. RAM stayed near 110 MB.

For scale, 800 a second is about 69 million messages a day. And no single site can get near that: the per-site flood brake starts holding a site back at 600 messages a minute, 10 a second.

what we tried that did nothing

Two ideas looked good on paper and changed nothing in the numbers, so they didn’t ship:

  • a bigger SQLite page cache
  • partial indexes on the messages table

We also built a version where the job runner runs as its own process, on its own core. At high load it was worse than one process, because SQLite allows one writer at a time and the two processes ended up waiting on each other. That branch stayed a branch. It’s a story for another post.

what this test did not measure

Being honest about the edges of a benchmark is part of the benchmark.

  • The internet. Load came over loopback. Real visitors add TLS, Cloudflare, nginx and their own network. Those add latency per message, not CPU on our core, but they’re not in these numbers.
  • Real Slack. The mock answers in about 50 ms and never says no. Real Slack accepts roughly one post per second per channel and returns 429s past that. Our queue handles 429s per channel, but a real Slack under load behaves differently than a mock.
  • Discord. Same path through the job runner, not tested separately.
  • Open connections. The generator held a modest number of sockets. The first real ceiling for many open widgets was nginx: it was allowed 768 connections per worker, about 3000 open widgets for the whole box. We raised it to 16,384 per worker. We haven’t pushed tens of thousands of idle sockets through it yet.
  • Dashboards. Nobody had the inbox open during the test, so no live updates were fanned out to the dashboard.
  • Power cuts. synchronous = NORMAL is safe against a crash of the process, which we can test. A power cut we took on trust from SQLite’s documentation.

what it means for you

The app that answers your visitors is one small process. On one core it takes 80 times what the flood brake lets a single site send. When it needs more, the path is known: move the Slack posts off the widget’s core without two SQLite writers fighting over one file.

We built Spookat to be small on purpose, and small turned out to be fast. The launcher is under 4 kb, there are 0 requests to our API before a click, and the server behind it takes 1000 messages a second on one core.

Want a chat widget that’s already been hammered on your behalf? Start a 14-day trial, no card.

liked it? get in before launch.

invites go out in small batches. then 14 days free, no card.