Skip to content

Benchmarks

These are honest, reproducible, engine-level numbers. They measure the cost of Atomo's core operations in-process — the data layer and the durable job lease engine — and deliberately exclude HTTP framing, the network, and GraphQL resolution. They answer "what does each core operation cost?", not "requests/sec through the full stack."

It also includes a co-located head-to-head vs Node — both at the data layer (node-postgres) and at the HTTP request layer (Atomo's axum server vs Fastify under k6). Bottom line up front: the roadmap's "3–5× faster than Node" line is not supported on either layer — Atomo is comparable-to- slower on raw throughput because it does more out of the box. It stays a target. Atomo's edge is footprint, hot-cache reads, and built-in capabilities, not raw speed (see Results).

Running it

Atomo harness (release, gated on a real Postgres):

bash
DATABASE_URL=postgres://… \
  cargo run --release -p atomo_server --example bench   # BENCH_ITERS=5000 to override (default 2000)

Action overhead harness (measures incremental cost of the event-to-action pipeline):

bash
DATABASE_URL=postgres://… \
  cargo run --release -p atomo_server --example action_overhead   # BENCH_ITERS=500 to override

Node baseline (node-postgres, raw SQL):

bash
DATABASE_URL=postgres://… BENCH_ITERS=2000 node bench/node-baseline.mjs   # needs `npm i pg`

Prisma baseline (Prisma Client v6.19, no built-in cache):

bash
cd bench/prisma-baseline && npm install && npx prisma generate
DATABASE_URL=postgres://… BENCH_ITERS=2000 node bench.mjs

Payload CMS baseline (Payload v3.32 local API, no built-in cache):

bash
cd bench/payload-baseline && npm install
DATABASE_URL=postgres://… PAYLOAD_CONFIG_PATH=$PWD/payload.config.ts \
  BENCH_ITERS=2000 npx tsx bench.mts

Measure co-located — both against a local Postgres on the same host. A remote DB inflates every write with a network round trip (we learned this the hard way; see Results). One reproducible way to do that on a dev box with Postgres in WSL2 is to run each in a container with --network host:

bash
# Atomo (builds in a rust container; target on container fs to avoid slow /mnt I/O)
docker run --rm --network host -v "$PWD":/app -w /app -e CARGO_TARGET_DIR=/tmp/t \
  -e DATABASE_URL=… rust:latest bash -c "cargo build --release -p atomo_server --example bench && /tmp/t/release/examples/bench"
# Node baseline
docker run --rm --network host -v "$PWD":/app -w /app -e DATABASE_URL=… \
  node:20 bash -c "npm i pg --no-save --silent && node bench/node-baseline.mjs"
# Prisma baseline
docker run --rm --network host -v "$PWD"/bench/prisma-baseline:/app -w /app \
  -e DATABASE_URL=… node:20 \
  bash -c "npm install --silent && npx prisma generate && node bench.mjs"
# Payload CMS baseline
docker run --rm --network host -v "$PWD"/bench/payload-baseline:/app -w /app \
  -e DATABASE_URL=… -e PAYLOAD_CONFIG_PATH=/app/payload.config.ts node:20 \
  bash -c "npm install --silent && npx tsx bench.mts"

Both harnesses warm up, time each operation serially, and print a markdown table of mean + p50/p95/p99 latency and ops/sec. Release-only (debug numbers are meaningless).

What's measured

BenchWhat it isolates
data layer: createone insert + model-event emission via AtomoClient::create
data layer: create_manya 100-row batch via AtomoClient::create_many (one txn), reported per-row
data layer: update_many / delete_manya single-row update / soft-delete by id (one txn, write + events)
data layer: find_many hot / colda bounded read (limit 20) via AtomoClient::find_many — hot = cache hit, cold = after a write invalidates the cache (DB round trip)
data layer: find_unique hot / colda point read by id via AtomoClient::find_unique — hot = cache hit, cold = cache miss
data layer: countAtomoClient::countSELECT COUNT(*) (not cached)
data layer: find_many + includefind_many with a hasMany relationship: 20 parent records, each resolving 3 children — measures the N+1 relation-resolution cost
data layer: find_many eventualsame as find_many hot but with ATOMO_CACHE_MODE=eventual — writes interleaved between reads do NOT invalidate the cache, showing cache staying hot under mixed load
job lease: 1 workerJobStore::lease throughput draining a queue (cap 50/call)
job lease: 8 workersconcurrent SELECT … FOR UPDATE SKIP LOCKED dispatch — shows lock-free scaling
HTTP request throughputa bare endpoint under k6 concurrency — Atomo's axum server vs Fastify; isolates the request runtime (see Full-stack HTTP below)
action overhead: baseline CRUDcreate with no event bindings — the floor (action_overhead example)
action overhead: event emissioncreate with events declared but no dispatcher — isolates event construction + broadcast cost
action overhead: dispatch + enqueuecreate with a live action dispatcher — measures binding match + job INSERT overhead
action overhead: worker CRUD callbackHTTP round-trip through /api/worker/crud/:model via oneshot — measures auth + capability check + CRUD + JSON serialization
crm: create Company / Contactcreate with a real-world CRM model (6–8 fields, FK relations, validations) — validates that field count doesn't change write cost
crm: create_many Leadbatch create with select/enum fields and two FK relations — per-row cost in a complex-model batch
crm: find_many filteredfind_many with WHERE on a select field (status) or FK field (companyId) — measures cache-hit read with non-trivial filters
crm: find_unique by idpoint read on a CRM Contact — same cache-hit path as simple Note, validates no per-field overhead
crm: mixed workloadinterleaved create Deal + find_many Lead — simulates a real CRM app pattern

Results

All numbers below are co-located — Atomo (Linux, in a rust container) and the Node baseline both run on the same host as Postgres, against the same local DB, so neither pays a network hop. Machine: Intel i5-13400 (10C/16T), 64 GB · Postgres in WSL2 (local) · release builds · 2000 iterations · 2026-06-08.

Footprint: the atomo-server release binary is 9.8 MB (single static binary, stripped + thin LTO) — the whole per-project runtime, vs a Node runtime (~50–90 MB) plus node_modules.

Atomo engine (co-located):

Benchmarkmean µsp50p95p99ops/sec
data layer: create (insert + event, single txn)3715366455087429269
data layer: create_many (per row, batch 100)777610710713 006
data layer: update_many (1 row by id, single txn)3709360746456835270
data layer: delete_many (1 row by id, single txn)3786361752607165264
data layer: update_many (per row, ~500 matched)3634373728 028
data layer: delete_many (per row, ~500 matched)3332373730 114
data layer: find_many hot (limit 20, cache hit)11.911122283 721
data layer: find_many cold (limit 20, cache miss)625570101212851 600
data layer: find_unique hot (cache hit)0.8111 219 298
data layer: find_unique cold (cache miss)54250084811911 845
data layer: count hot (cache hit)0.26 571 295
data layer: count cold (cache miss)1111102516482238900
data layer: find_many + include (20 notes × 3 tags)10457751769 582
data layer: find_many eventual (hot through writes)42.5377311223 528
job lease: 1 worker1049 634
job lease: 8 workers (SKIP LOCKED)3231 658

CRM-schema results (real-world model complexity):

The simple-model bench above uses a trivial Note {id, title} — one field, no relations. To validate that model complexity doesn't change the picture, the CRM scenarios use the full CRM schema shape: 5 models (Company, Contact, Lead, Deal, Activity), 6–8 fields each, FK relations, select/enum constraints, and validation rules. Tables are bench_crm_-prefixed to isolate from the simple bench.

Benchmarkmean µsp50p95p99ops/sec
crm: create Company (7 fields)4202400956177601238
crm: create Contact (6 fields + FK)4248391262387577235
crm: create_many Lead (per row, batch=50)2592683813813 862
crm: find_many Lead WHERE status='qualified' hot2018224050 641
crm: find_many Contact WHERE companyId=X hot2018233849 982
crm: find_unique Contact by id hot1.2112822 247
crm: mixed (create Deal + find_many Lead)4482439061887351223

Machine: Intel i5-13400 (10C/16T), 64 GB · Postgres in WSL2 (local) · release build (Docker rust:latest, --network host) · 2000 iterations · 2026-06-09.

Key takeaways:

  • Writes scale with transaction count, not field count. CRM create (~4.2 ms) ≈ Note create (~4.1 ms, same run) — both are one txn = one fsync. Adding 6 more fields and a FK costs nothing measurable.
  • Batch creates stay efficient. CRM create_many at 259 µs/row (batch=50) is proportional to the Note batch at 142 µs/row (batch=100) — the per-row cost is dominated by the multi-row INSERT, not per-field overhead. The difference is batch size (50 vs 100), not schema complexity.
  • Filtered reads are fast. find_many with a WHERE clause on a select/enum field (20 µs) or FK field (20 µs) — cache-hit performance is consistent regardless of filter complexity.
  • Point reads stay sub-µs class. CRM find_unique by id at 1.2 µs (822 k ops/s) vs Note at 0.7 µs (1.4 M ops/s) — the slight difference is the larger cached record, not per-field overhead.
  • Mixed workloads (interleaved create + read) cost ~4.5 ms — the write dominates, and the hot read adds negligible time.

Batch inserts: create_many commits a 100-row batch via two multi-row INSERTs (the rows, then their events) in one transaction — so the per-row cost drops from ~3.9 ms to ~77 µs — roughly 50× (≈13 k rows/sec). One fsync amortized across the batch and one round trip per statement instead of per row. (An earlier per-row-in-one-transaction version was ~407 µs/row — the multi-row INSERT cut another ~5× by eliminating the per-row round trips.) For very large loads a COPY-based path could push further still — a follow-up.

Single-row writes (create / update_many / delete_many) all sit in the same ~3.7–3.9 ms band — each is one transaction = one fsync (the write + its events commit together). The number is dominated by Postgres commit durability, not Atomo; relax it with co-located storage, synchronous_commit, or batching (create_many). (Each was ~2× this before its event write was folded into the same transaction.)

Bulk update_many / delete_many: a single call matching ~500 rows costs ~33–36 µs per row (≈28–30 k rows/sec) — ~110× cheaper per row than a one-row call, because it's one UPDATE for all matched rows (the SET/WHERE params don't scale with the match count) plus one chunked event INSERT, all in one transaction. The event inserts are chunked under Postgres' bind-param limit, so bulk operations are safe at any size (a 11 000-row batch is a regression test).

Read paths — cold vs hot: a hot find_many (cache hit) returns in ~12 µs (84 k/s); a cold read (cache miss, after a write invalidates the model cache) costs ~625 µs — the actual Postgres query time (~52× slower). find_unique (point read by id) is even faster when cached: ~0.8 µs (1.2 M ops/s), because the cached value is a single record vs a list. Cold find_unique is ~542 µs. count is now cached under the same per-model key space: hot count returns in ~0.2 µs (6.6 M ops/s), cold (after a write invalidates) costs ~1111 µs (the DB round trip).

Relation resolution (include): find_many with a hasMany include (20 parent notes, each resolving 3 child tags) costs ~104 µs — the child lookups hit the cache on repeated calls, so the incremental cost of N+1 relation resolution is low when the cache is warm.

Eventual mode: with ATOMO_CACHE_MODE=eventual, writes do not invalidate the read cache (the TTL alone bounds staleness). Under a mixed read/write load the cache stays hot — reads cost ~43 µs (23 k/s) even with a write between every read, vs ~625 µs cold in strong mode. The trade-off is bounded staleness (up to the TTL) for ~15× faster reads under write pressure.

Head-to-head: Atomo vs Node (node-postgres), co-located

The Node baseline (bench/node-baseline.mjs) does the equivalent raw SQL via node-postgres, same machine, same DB, same serial-latency method.

OperationAtomoNode (node-pg)Read
persist a record + event3715 µs (269/s)3159 µs (317/s)~on par (~1.2×) — Atomo commits the row + its event in one transaction (one fsync), same as the Node txn; both fsync-bound. (Was ~1.9× before the single-transaction fix — see below.)
raw insert (Node) / —2915 µs (343/s)the DB write floor
read 20 rows12 µs cache-hit (84 k/s)469 µs (2.1 k/s)Atomo's in-process read cache wins ~40× on hot reads; a cold Atomo read (cache miss, ~625 µs) is ~the same as Node (both ≈ the PG query)
point read (find_unique)0.8 µs cache-hit (1.2 M/s)sub-microsecond point-read cache; cold = ~542 µs (the DB round trip)
footprint9.8 MB binaryNode runtime + node_modules
durable job lease31 658/s (8 workers)no Node equivalent (Atomo-only)

Honest conclusions

  • Atomo's create is ~on par with raw node-postgres for an equivalent record+event write (~1.2×), now that it commits the row and its event in one transaction (one fsync). It is still not faster — both are bounded by Postgres commit durability — so the roadmap's "3–5× faster than Node" stays a target (and the HTTP-layer test below shows the same: not faster there either). Atomo isn't a speed play; it's batteries + footprint.

    Optimization, this is the benchmark working: the measurement caught that create was doing two autocommit writes (the row, then event_log) = two fsyncs ≈ 2× the latency. Collapsing them into one transaction dropped create from 5998 µs → 3715 µs (−38%, +61% throughput) and took the Node gap from ~1.9× to ~1.2×. A benchmark that finds a real fix is worth more than one that flatters.

  • Where Atomo wins: hot reads (list cache ~40×, point-read cache ~1.2 M ops/s), footprint (a 9.8 MB binary vs a Node install), and capabilities Node has no built-in answer for — the durable job lease engine (28 k leases/s, scaling from 1→8 workers via SKIP LOCKED), event sourcing, and the action dispatcher.
  • The trade, stated plainly: Atomo costs ~2× a bare insert on writes (both Postgres-fsync-bound) in exchange for a 10 MB self-contained binary with event sourcing, hooks, durable jobs, sub-µs point-read caching (1.2 M ops/s), and a typed, schema-driven backend (API + admin) out of the box. Not raw speed — built-ins + footprint. Most apps are read-heavy, where the cache wins and the write cost rarely bites.

Why co-located matters (a cautionary data point): an earlier run with Postgres on a separate host (Windows → WSL2 over the LAN) showed Atomo create at ~9 ms — but that was the network hop, not Atomo. Always measure with a local DB; a remote DB inflates every write.

Head-to-head: Atomo vs Prisma, co-located

The Prisma baseline (bench/prisma-baseline/) uses Prisma Client v6.19 against the same co-located Postgres, same serial-latency method. Prisma v6 does connection pooling, query building, and result mapping via its Rust-based query engine — more than raw node-postgres, but has no built-in read cache (every find hits the database; caching requires the paid Prisma Accelerate add-on or an external layer). Atomo's in-process cache is what makes reads lopsided.

OperationAtomoPrismaRead
create + event (txn)4429 µs (226/s)4941 µs (202/s)comparable — both fsync-bound; Atomo slightly faster (one binary, no IPC)
create (bare, no event)4076 µs (245/s)Prisma's query-engine overhead vs raw node-pg (~2.9 ms)
createMany (per row, batch 100)75 µs (13 k/s)64 µs (16 k/s)Prisma slightly faster (no event-log INSERT in the batch)
findMany (limit 20)12 µs hot (84 k/s)1340 µs (746/s)Atomo's cache = ~112× Prisma; cold Atomo (~625 µs) is ~2× faster (one binary, no IPC)
findUnique0.8 µs hot (1.2 M/s)607 µs (1.6 k/s)Atomo's point-read cache = ~760× Prisma
count0.2 µs hot (6.6 M/s)828 µs (1.2 k/s)Atomo's cached count = ~4 100× Prisma; cold Atomo (~1111 µs) is comparable
findMany + include (20 × 3 tags)104 µs (9.6 k/s)1177 µs (850/s)cached relation resolution = ~11× Prisma
update (1 row)3985 µs (251/s)4380 µs (228/s)both fsync-bound; Atomo includes event emission
delete (1 row)4107 µs (244/s)4148 µs (241/s)effectively identical — the fsync dominates

Honest conclusions

  • Writes are a wash. Both Atomo and Prisma sit in the same ~4–5 ms band for single-row writes — Postgres fsync dominates, not the runtime. Atomo does more per write (event emission) and is still comparable. Batch writes are similar (~64–75 µs/row).
  • Reads are where Atomo pulls ahead. Prisma has no built-in read cache — every findMany and findUnique hits the database. Atomo's in-process cache serves hot reads in sub-µs to ~12 µs vs Prisma's ~600–1300 µs. For read-heavy workloads (most apps), this is the decisive gap.
  • Prisma is the more direct comparison than raw node-postgres. Most real backends use an ORM, not hand-written SQL. Atomo vs Prisma is the comparison someone evaluating both would actually make.
  • What Prisma has that Atomo doesn't (yet): a mature ecosystem, broad ORM features (raw queries, nested writes, aggregations), Prisma Studio, Prisma Accelerate (hosted cache/pool). What Atomo has that Prisma doesn't: built-in event sourcing, hooks, durable job engine, admin UI, a 9.8 MB single binary, and the cache that makes this comparison lopsided on reads.

Head-to-head: Atomo vs Payload CMS, co-located

The Payload baseline (bench/payload-baseline/) uses Payload CMS v3.32 (local API — payload.create / payload.find / etc., no HTTP server) against the same co-located Postgres, same serial-latency method. Payload v3 uses Drizzle ORM under the hood and does more per operation than Prisma or raw SQL: field-level hooks, access control evaluation, locked-document tracking, and preference cleanup. Like Prisma, Payload v3 has no built-in read cache — every find / findByID hits the database.

OperationAtomoPayload CMSRead
create4429 µs (226/s)6186 µs (162/s)Atomo ~1.4× faster (includes event emission; Payload runs hooks + locked-doc tracking)
find (limit 20)12 µs hot (84 k/s)1495 µs (669/s)Atomo's cache = ~125× Payload; cold Atomo (~625 µs) is ~2.4× faster
findByID / findUnique0.8 µs hot (1.2 M/s)689 µs (1.5 k/s)Atomo's point-read cache = ~860× Payload
count0.2 µs hot (6.6 M/s)393 µs (2.5 k/s)Atomo's cached count = ~2 000× Payload; cold Atomo (~1111 µs) is slower (RLS scope overhead)
find + depth/include104 µs (9.6 k/s)3417 µs (293/s)Atomo cached = ~33×; Payload's depth resolution is heavier (each related doc goes through its full find pipeline)
update (1 row)3985 µs (251/s)7271 µs (138/s)Atomo ~1.8× faster (includes event emission; Payload runs before/after hooks + locked-doc update)
delete (1 row)4107 µs (244/s)7616 µs (131/s)Atomo ~1.9× faster (soft-delete + event; Payload does hard-delete + preference cleanup + locked-doc cleanup)
bulk create (per row, ×100)75 µs (13 k/s)5434 µs (184/s)Atomo's multi-row INSERT = ~72× per row; Payload has no native bulk create (sequential)

Honest conclusions

  • Payload does more per operation — access control hooks, locked-document tracking, preference cleanup on delete — so it's expected to be slower on writes. Atomo does event sourcing per write (which Payload doesn't) and is still ~1.4–1.9× faster on mutations.
  • Reads are the biggest gap — same story as Prisma but wider, because Payload's find pipeline (access control evaluation, hook execution, field transforms) adds overhead on top of the DB query. Atomo's cache bypasses all of that on hot reads. Even cold Atomo reads (pure DB) are ~2× faster than Payload's find, because Atomo's query path is thinner (one compiled binary, no JS hook pipeline for a no-hook model).
  • Count is now cached too. Atomo's count was previously slower than Payload's (RLS scope overhead); now it caches under the same model key space — hot count is 0.2 µs (~2 000× Payload). Cold count (~1111 µs) is still heavier than Payload's 393 µs due to RLS scoping.
  • Payload is the closest "apples-to-apples" competitor — both are schema-driven backend frameworks with admin UI, hooks, and relation resolution. The difference: Atomo is a compiled Rust binary with an in-process cache; Payload is a Node.js CMS with a richer extension/admin ecosystem. For read-heavy workloads the gap is decisive; for write-heavy workloads with heavy hook logic, Payload's JS ecosystem may be the right trade.

Full-stack HTTP: request throughput

The numbers above are in-process. This measures the HTTP request runtime under concurrency — Atomo's axum/tokio server vs a fast Node framework — on a bare endpoint (small JSON, no DB), so it isolates request handling (the layer the data-layer bench excludes). Both co-located; k6, 50 VUs, 15 s. Atomo /version vs Fastify /health.

Serverreq/secp95max
Node Fastify (bare routing)43 2102.0 ms195 ms
Atomo lean (RUST_LOG=warn, security headers off)30 1692.3 ms9 ms
Atomo default (full production middleware)16 9963.7 ms15 ms

Honest conclusions

  • Atomo is not faster than a fast Node framework at the HTTP layer — comparable when lean, ~2.5× slower by default. So "3–5× faster than Node" is not supported at the HTTP layer either (nor the data layer). Across everything measured, Atomo's value is not raw speed.
  • Atomo does more per request out of the box. Its default path runs tracing/logging, security headers, CORS, a rate-limit token bucket, and request-id propagation; the bare Fastify does routing only. A Fastify with equivalent middleware would close the gap — the honest framing is batteries-included request path vs bare router.
  • Now fixed in the default. The default→lean jump (17 k → 30 k req/s) was almost all per-request INFO logging. The per-request completion log is now emitted at DEBUG, so a default deployment performs like the "lean" row (~30 k req/s), not the old ~17 k — the benchmark's clearest finding, banked as the default. (Boot/error logs stay at INFO+; set RUST_LOG=debug to restore per-request logs; ship as LOG_FORMAT=json to a collector.) The ~17 k "default" row above reflects pre-change behavior.
  • A single-IP flood also trips Atomo's rate limiter (fast 429s — real protection, a load-test artifact); raise RATE_LIMIT_RPS when benchmarking, as we did.

Follow-up: bare-endpoint throughput (no DB). For authenticated CRUD under load, see the Authed Load section below.

Harness: bench/http/ (schema.ts, node-server.mjs, load.js, seed.sql) — boot each server co-located, then k6 run against it.

Authed load: full-stack CRUD under concurrency

The engine-level and bare-HTTP benches above isolate individual layers. This measures the full production path under realistic concurrency — JWT auth, session verification, GraphQL resolution, read cache, write + event sourcing — all together. k6, 50 VUs, 60 s steady state, server in Docker (--network host), k6 on the host. Co-located Postgres. Pool size 30.

Four scenarios, each isolating a workload shape:

ScenarioWorkloadReq/sp50p95Errors
mixed80% read, 10% create, 5% update, 5% delete2 3845.2 ms13.8 ms0%
read-only100% reads (limit 20)2 3975.0 ms10.7 ms0%
write-only100% creates1 36117.8 ms30.7 ms0%
mutate50% update, 50% delete201131 ms482 ms0%

Machine: Intel i5-13400 (10C/16T), 64 GB · Postgres in WSL2 (local) · release build · pool size 30 · RATE_LIMIT_RPS=999999 · 2026-06-08.

Per-operation latency (from the mixed scenario)

Operationavgp50p95
read (limit 20)5.8 ms4.5 ms9.6 ms
create14.1 ms11.4 ms17.8 ms
update13.1 ms11.4 ms17.9 ms
delete14.0 ms11.3 ms17.7 ms

Honest conclusions

  • Reads dominate throughput. Under the 80/10/5/5 mixed workload, the read cache keeps p95 at 13.8 ms — most iterations hit the cache and return without a DB round trip.
  • Writes are ~3× slower than reads (~14 ms vs ~5 ms) — each write commits a row mutation + event in one transaction (one fsync), which is the expected cost.
  • The mutate scenario is a worst-case stress test, not a realistic workload. All 50 VUs hit the same fallback row (the seeded data has no createdIds for the delete path to consume), causing heavy row-level lock contention on Postgres. The p95 of ~482 ms reflects lock wait time, not Atomo overhead. Real workloads spread updates across many rows.
  • Pool size matters. An earlier run with pool=10 (default) under the same mixed workload showed 309 req/s and p95 446 ms — 7.7× lower throughput and 32× higher tail latency than pool=30. The pool bottleneck (5:1 VU-to-connection contention) dominated. DATABASE_POOL_MAX is now configurable (default 20).

Harness: bench/authed-load/ (load.js, schema.ts, Dockerfile, docker-run.sh).

bash
# Quick run (from WSL, with Docker):
DATABASE_URL=postgres://… ./bench/authed-load/docker-run.sh

# Individual scenario:
k6 run -e BASE=http://127.0.0.1:3099 -e SCENARIO=read-only bench/authed-load/load.js

When to use what

The benchmarks above measure cost. This section maps cost to product decisions — when do those numbers matter, and when don't they?

WorkloadBetter fitWhy (data-backed)
Read-heavy API (dashboards, feeds, listings)AtomoHot-cache reads: 12 µs list / 0.8 µs point-read vs Payload's ~700–1500 µs and Prisma's ~600–1300 µs. Most apps are 80%+ reads — the cache is the decisive gap.
Bulk data ingestion (imports, migrations, ETL)Atomocreate_many: 75 µs/row (13 k/s) vs Payload's 5434 µs/row (184/s) — ~72× per row. Bulk update_many/delete_many: ~33 µs/row (28–30 k/s). Payload has no native bulk path.
Lightweight self-hosted backend (SaaS API, internal tools)AtomoA 9.8 MB static binary with auth, event sourcing, durable jobs, schema-driven API, and admin UI. No Node runtime, no node_modules, no process manager. Deploy a single binary + Postgres.
High-concurrency mixed workloadAtomoThe authed load test shows 2 384 req/s at p95 13.8 ms under 50 VUs (JWT + GraphQL + cache + events). The in-process cache means reads don't contend for DB connections.
Content team CMS / editorial workflowPayloadPayload's admin UI is a polished React app with live preview, rich text (Lexical/Slate), media library, draft/publish workflow, and localization — built for content editors, not developers. Atomo's admin is functional but developer-oriented.
Plugin ecosystem / custom admin extensionsPayloadPayload has a mature plugin ecosystem (SEO, form builder, nested docs, redirects, search) and a React-based admin that supports custom field components, views, and providers. Atomo's extension model is actions & workers — powerful but earlier-stage.
Write-heavy with complex hooksEitherSingle-row writes are fsync-bound in both (~4–8 ms). Atomo is ~1.4–1.9× faster per write (includes event sourcing), but if your hooks need npm packages or native deps, Payload's JS runtime is the pragmatic choice.
Durable background jobsAtomoBuilt-in job engine with SKIP LOCKED lease dispatch: 31 658 leases/s (8 workers). Payload has no built-in job queue — you'd add BullMQ, pg-boss, or similar.

What this table is not

This is not "Atomo replaces Payload." They overlap on schema-driven CRUD + admin but diverge on audience: Atomo is a backend runtime (API-first, event-sourced, small footprint); Payload is a headless CMS (content-first, editor-friendly, extension-rich). The benchmark numbers inform which trade-offs matter for a given project — they don't make the choice for you.

  • If your team is content editors who need rich text, media management, and a polished admin experience — Payload is the right tool regardless of the latency gap.
  • If your team is developers shipping an API backend where reads dominate, footprint matters, or you need event sourcing and durable jobs out of the box — that's where the numbers above translate to a real product advantage.

Caveats (read before quoting a number)

  • In-process, not HTTP. Add network + HTTP + GraphQL overhead for end-to-end figures.
  • Environment-sensitive. Postgres locality dominates write latency; a remote DB inflates every number. Always record the machine + DB location alongside results.
  • No CI perf-gate. Perf on shared CI runners is too noisy to gate on; track the trend manually.
  • Run-to-run variance. fsync-bound write latency wobbles (±~20% between runs); compare medians and re-run before drawing a conclusion. The relative picture (Atomo vs Node, reads vs writes) is stable; the absolute µs are not.
  • A wrong or gamed benchmark is worse than none — keep these reproducible and conservative.

See also

Released under the AGPL-3.0 License.