These numbers are development measurements, not universal capacity guarantees. They include the runtime's Active Record and database query overhead and will vary with hardware, schema size, connection pools, durability settings, and contention.
Run the four-role idle harness with:
bundle exec ruby -Ilib benchmark/idle_polling.rbIt warms each interval for three seconds, measures for ten seconds, and reports process user plus system CPU time divided by wall time. Measured August 16, 2026 on an Apple M5 with Ruby 4.0.6 and SQLite 3.53.2. The before run used 0.13.0; the after run used the prepared 0.13.1 tree.
| Fast interval | Before polls/s | Before CPU | After polls/s | After CPU |
|---|---|---|---|---|
| 20 ms | 165.340 | 8.401% | 3.998 | 0.947% |
| 100 ms | 38.396 | 2.925% | 3.999 | 0.482% |
| 500 ms | 7.998 | 2.061% | 3.996 | 0.283% |
The after run reached the one-second ceiling for the actor, effect, reminder, and broadcast roles. These are developer-laptop measurements, not a CPU guarantee; timer scheduling, YJIT, the SQLite file, and unrelated host activity affect short samples.
Five SQLite samples measured durable enqueue through committed completion after 2.5 seconds of idleness. The polling-only multi-process harness submits just after an empty pass, so it measures approximately the full polling wait rather than average arrival latency.
| Topology | 0.13.0 p50 | Prepared 0.13.1 p50 |
|---|---|---|
| One process, in-process wake-up | 43.360 ms | 50.339 ms |
| Two processes, polling only | 117.787 ms | 1,028.006 ms |
The local wake-up keeps the one-process path prompt after backoff. The polling-only row is the explicit tradeoff: use PostgreSQL notifications or optional Redis Pub/Sub when separate processes need low-latency delivery.
An adoption evaluation measured Solid Objects 0.2.0 from a macOS Rails process against Docker MySQL 8 over a published TCP port. The host used Rails 8.1, Ruby 4.0.5, roughly 165 gems, and an approximately 2,200-line schema.
| Operation | Existing key-value row | Solid Objects |
|---|---|---|
| Write | median 4.7 ms | median 60 ms, minimum 33 ms, maximum 163 ms |
| Read | median 0.2 ms | median 28 ms |
| First call for a cold identity | approximately 5 ms | 315 ms |
This is not a controlled cross-database benchmark and no sample count was recorded. It is still useful adoption evidence: a synchronous actor call is not a substitute for a direct indexed row read when single-digit-millisecond latency is the requirement. The first call includes actor-instance creation, caller-process registration, message enqueue, activation claim, handler execution, fenced commit, and activation release.
Measured 2026-08-06 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1, and SQLite 3.51.0. Each throughput scenario used 200 operations; the concurrent scenario used four worker threads.
| Scenario | Result |
|---|---|
| Enqueue, one actor | 629.2 messages/s |
| Claim, 200 actors | 1,038.4 claims/s |
| Process, 40 actors round-robin | 548.1 messages/s |
| Process, 200 cold actors | 121.5 messages/s |
| Process, one hot actor | 726.4 messages/s |
| Process, four workers | 556.5 messages/s |
| Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
| Activation reuse | 98.0%, four activations for 200 messages |
Query counts are a property of the code rather than the host, so they are
tracked separately. Re-measured 2026-08-10 against 0.10.0 on SQLite with
bundle exec ruby benchmark/query_count.rb, which is deterministic across runs:
| Scenario | Queries |
|---|---|
| One message turn | 26 |
| The caller of one synchronous call | 49 |
| One synchronous call, caller plus the turn it waits on | 75 |
A worker turn fell from the 29 recorded earlier. The caller count is unchanged at 49, and the combined figure is new: a synchronous call is a caller and a worker turn, and only the sum says what the database actually serves.
Counting is scoped to the measuring thread. A worker loop polls whether or not a call is in flight, so a process-wide count folds however many polls happened to land inside the window into the result. That is also why the caller and the turn are measured separately rather than by watching both threads at once.
A synchronous call costs far more queries than a worker turn because the caller also registers or heartbeats its caller process, claims the activation, and observes the result. Query count, not query time, dominates synchronous latency on a networked database: measured locally against SQLite, database time is roughly 5% of a call and the remaining 95% is Ruby.
The difference between the SQLite development result and the MySQL adoption result is why Solid Objects does not publish one latency promise. Network topology, adapter behavior, host schema, logging, callbacks, and contention all matter.
Measured 2026-08-09 on an Apple M5 with 200 iterations, for one actor mutation that changes three components.
| Delivery path | Browser requests | Server render time |
|---|---|---|
| Individual component refreshes | 3 | 0.535 ms |
| Batched refresh | 1 | 0.249 ms |
| State payload broadcast | 0 | 0.100 ms |
The request column is the headline. Server render time is small in every path, so the win is not faster rendering, it is fewer round trips: each individual refresh costs a full HTTP request through the Rails middleware stack, and a batch replaces three of those with one. A state payload removes the HTTP leg entirely by travelling on the Action Cable connection the page already holds.
These are server-side numbers. They do not include network latency, Action Cable delivery, or browser rendering, which dominate wall-clock time in a real deployment and make the request-count difference matter more than it appears here. End-to-end latency against a deployed application has not been measured.
Measured 2026-08-09 against PostgreSQL 17, 30 samples, with polling_interval
at its 100 ms default and a signal sent 2 ms after the waiter began.
| Wake-up strategy | p50 | p95 |
|---|---|---|
In-process WakeUp |
103.7 ms | 105.1 ms |
WakeUpAdapters::Postgresql |
2.9 ms | 5.1 ms |
The in-process wake-up cannot reach another process, so a worker waits out the full polling interval no matter how quickly the web process committed. The notification adapter removes that floor rather than shrinking it, and the polling interval remains the upper bound if a notification is missed.
The storage cost is deterministic even when latency is not:
- the first call for one actor identity inserts one
solid_objects_instancesrow; - every direct,
sync, query, attribute read, orasynccall inserts one permanentsolid_objects_messagesrow; - ready and claimed membership rows exist only while the call is pending or executing;
- one caller process row is registered per application process that performs synchronous calls;
- effects and observable changes add outbox rows; and
- reminders add one row per named actor reminder.
Attribute reads are therefore not free snapshots from the instance row. They are ordered durable query messages and grow message history exactly like writes.
Built-in pruning previews by default and must be scheduled explicitly. Budget message growth before retention as:
daily durable messages = daily actor writes + daily actor reads + daily callbacks
Review the retention requirements before adopting a high-volume surface.
Use reference.snapshot for an authorized current-state read when mailbox
ordering is unnecessary. It avoids a message row but can observe state before
an in-flight turn commits.
Run the adoption benchmark against a dedicated empty database with the same adapter and topology as production:
COUNT=25 \
SOLID_OBJECTS_DATABASE_URL=mysql2://localhost/solid_objects_benchmark \
bundle exec ruby -Ilib benchmark/adoption_latency.rbIt reports the first cold call, warm synchronous writes, ordered reads, and durable instance/message growth. Run it near the application process, with production-like TLS and network boundaries where applicable.
The scripts and invocation examples are in the development guide. PostgreSQL and MySQL should be benchmarked independently before selecting production capacity.