bench/compare.exs runs Licet and SpiceDB side by side on one machine, against one Postgres server, with the same schema, relationships, and requests. This page describes the method, how to run it, and one set of results. The script lives in the GitHub repository and isn't part of the Hex package.
Method
Both systems store their data in the same Postgres server, each in its own database: Licet in licet_bench, SpiceDB in spicedb_bench. Neither shares tables or locks with the other. Licet runs inside the benchmark process. SpiceDB runs as a server on the same machine and is called through its HTTP API.
The dataset is generated by the script and loaded into both systems before anything is timed. It has 25,161 relationships:
- 1,000 users and 100 groups of 20 users, where each of the first 50 groups also contains one of the others;
- a three-level folder tree of 10 root folders, 100 middle folders, and 1,000 leaves, where each root folder is viewable by one group;
- 10,000 documents in the leaves, each with an owner, and every fifth with a direct viewer.
definition user {}
definition group {
relation member: user | group#member
}
definition folder {
relation parent: folder
relation viewer: user | group#member
permission view = viewer + parent->view
}
definition document {
relation parent: folder
relation owner: user
relation viewer: user | group#member
permission view = owner + viewer + parent->view
}Each scenario sends the same request to both systems:
| Scenario | Request |
|---|---|
| Direct check | A user views a document they own. |
| Nested check | A user views a document through three folders and a nested group. |
| Denied check | A user who can't view the document, so the whole graph is walked. |
check_bulk | 100 documents for one user, half allowed. |
lookup_resources | The 109 documents, of 10,000, that one user can view. |
Before timing, the script requires both systems to give the same answer to every request.
Each scenario runs at two consistency levels:
- Full: Licet
consistency: :fulland SpiceDBfullyConsistent. Both read the latest data and neither uses its cache. - Default: Licet's default
:minimize_latencyand SpiceDB'sminimizeLatency. Both answer from a snapshot that is up to 5 seconds old, and both cache results for that snapshot.
After those two passes, the script grants and revokes 100,000 relationships on documents no scenario reads, so both systems keep that many deleted rows until garbage collection, and reruns the full-consistency scenarios.
Each one runs with one caller, then with eight concurrent callers. Benchee warms up for 2 seconds and measures for 5.
Running it
Start Postgres, then SpiceDB with the Postgres datastore:
createdb -h 127.0.0.1 -p 54329 -U postgres spicedb_bench
docker run --rm --network host authzed/spicedb datastore migrate head \
--datastore-engine postgres \
--datastore-conn-uri "postgres://postgres:postgres@127.0.0.1:54329/spicedb_bench?sslmode=disable"
docker run --rm --network host authzed/spicedb serve \
--datastore-engine postgres \
--datastore-conn-uri "postgres://postgres:postgres@127.0.0.1:54329/spicedb_bench?sslmode=disable" \
--http-enabled --grpc-preshared-key test
The release binary takes the same arguments. Then run the script from the project root:
MIX_ENV=test mix run bench/compare.exs
It creates licet_bench, loads the dataset into both systems, runs every scenario, and prints a summary table. LICET_SPICEDB_URL points it at another SpiceDB server; PGHOST, PGPORT, PGUSER, and PGPASSWORD at another Postgres. BENCH_TIME and BENCH_WARMUP change the seconds per measurement.
Results
One run on an Intel i5-10300H (4 cores, 8 threads) with 8 GB of memory, Elixir 1.19.5 on Erlang/OTP 28, Postgres 15.19 in Docker, and SpiceDB 1.56.2 in Docker with host networking. Times are medians; ips is calls per second.
Full consistency
| Scenario | Callers | Licet median | Licet ips | SpiceDB median | SpiceDB ips |
|---|---|---|---|---|---|
| Direct check | 1 | 1.46 ms | 608 | 1.52 ms | 617 |
| Direct check | 8 | 3.07 ms | 303 | 2.71 ms | 321 |
| Nested check | 1 | 6.17 ms | 155 | 1.48 ms | 638 |
| Nested check | 8 | 13.4 ms | 68 | 2.27 ms | 369 |
| Denied check | 1 | 4.97 ms | 186 | 1.31 ms | 699 |
| Denied check | 8 | 12.2 ms | 80 | 2.27 ms | 396 |
check_bulk, 100 items | 1 | 13.9 ms | 66 | 7.11 ms | 128 |
check_bulk, 100 items | 8 | 32.6 ms | 29 | 16.3 ms | 56 |
lookup_resources | 1 | 21.4 ms | 43 | 6.55 ms | 142 |
lookup_resources | 8 | 49.0 ms | 19 | 16.3 ms | 55 |
Default consistency
| Scenario | Callers | Licet median | Licet ips | SpiceDB median | SpiceDB ips |
|---|---|---|---|---|---|
| Direct check | 1 | 20 μs | 39,600 | 0.75 ms | 1,200 |
| Direct check | 8 | 68 μs | 13,300 | 1.50 ms | 582 |
| Nested check | 1 | 15 μs | 46,000 | 0.75 ms | 1,200 |
| Nested check | 8 | 66 μs | 13,900 | 1.58 ms | 544 |
| Denied check | 1 | 23 μs | 36,200 | 0.76 ms | 1,200 |
| Denied check | 8 | 66 μs | 13,900 | 1.49 ms | 606 |
check_bulk, 100 items | 1 | 1.23 ms | 745 | 5.62 ms | 170 |
check_bulk, 100 items | 8 | 3.53 ms | 263 | 13.7 ms | 67 |
lookup_resources | 1 | 20.2 ms | 48 | 5.49 ms | 168 |
lookup_resources | 8 | 65.9 ms | 15 | 15.2 ms | 59 |
Full consistency after write churn
After granting and revoking 100,000 relationships on unrelated documents:
| Scenario | Callers | Licet median | Licet ips | SpiceDB median | SpiceDB ips |
|---|---|---|---|---|---|
| Direct check | 1 | 1.48 ms | 621 | 1.87 ms | 467 |
| Direct check | 8 | 3.37 ms | 279 | 2.78 ms | 326 |
| Nested check | 1 | 6.62 ms | 145 | 1.89 ms | 455 |
| Nested check | 8 | 14.6 ms | 64 | 3.64 ms | 239 |
| Denied check | 1 | 7.03 ms | 131 | 1.94 ms | 466 |
| Denied check | 8 | 14.4 ms | 67 | 2.93 ms | 310 |
check_bulk, 100 items | 1 | 30.3 ms | 32 | 11.1 ms | 88 |
check_bulk, 100 items | 8 | 50.9 ms | 19 | 20.5 ms | 45 |
lookup_resources | 1 | 28.0 ms | 35 | 7.27 ms | 131 |
lookup_resources | 8 | 60.9 ms | 16 | 18.2 ms | 49 |
Reading the results
With the default consistency, a repeated check is a lookup in an ETS table inside your application, so Licet answers in microseconds where SpiceDB needs a network round trip. This is the common case: most checks in an application repeat within the quantization window.
When nothing is cached, a check reads one level of the graph per query and covers every relation that level needs on the same objects. check_bulk groups checks that share a resource type, permission, and subject, so 100 items take about 14 ms instead of hundreds. A nested check still walks several levels in sequence and stays slower than SpiceDB; a direct check, which is one query on both sides, takes about the same time.
Deleted rows waiting for garbage collection are indexed by their lookup columns. After 100,000 such rows, single-check times stay close to the run without them. Bulk and lookup pick up some cost, as they do on SpiceDB.
lookup_resources walks back from the subject on both sides, and Licet doesn't cache lookups at either level. SpiceDB is about three to four times faster.
Keep in mind:
- SpiceDB pays for an HTTP round trip and its JSON gateway, while Licet runs in process. This is the real trade-off of embedding authorization, but SpiceDB's gRPC API would be somewhat faster than the HTTP gateway used here.
- SpiceDB runs as a single node. In production it usually runs several nodes that share their caches through dispatch, which this benchmark doesn't exercise.
- One laptop runs Postgres, SpiceDB, and the benchmark, so they compete for the same cores, especially with eight callers.
Evaluator micro-benchmark
bench/check.exs times only Licet's evaluator: an in-memory check of a direct relation, with no Postgres.
mix run bench/check.exs
On the same machine, it runs at about 370,000 checks per second, with a median of 1.9 μs. Treat it as a baseline for the evaluator rather than for a running instance.