We wanted to answer a simple question: can 10 people use the same large Canvas at the same time? Our first test failed. The final test passed.
The final test used 10 real browser sessions. Each session opened a Canvas with 1,000 nodes and 2,000 connections. All 10 people joined in under 20 seconds. Moving a node stayed smooth.
We found two separate problems. The server did too much work when people joined. The browser also repeated work after someone moved a node. Fixing only one problem would not have been enough.
What changed
- Notification startup now needs fewer database connections.
- Moving a node no longer makes the browser measure every node again.
- The test grows in steps, so each failure has a clear cause.
How we tested it
We tested the same optimized build that would be prepared for a release. This removed development compiler work from the results.
Playwright opened Chromium without visible browser windows. The test data included 10 users, eight Canvases, 10,000 saved Canvas changes, and 10,000 notifications. The browsers and server ran on one computer.
The first test found two limits
One person could open the largest Canvas. Two people exposed a slow browser frame. Ten people could not finish loading, even on a much smaller Canvas.
This told us the size of the Canvas was not the only problem. We needed to study the server and the browser separately.
Show every result from the first test
| Users | Nodes | Edges | Result | Observed boundary |
|---|---|---|---|---|
| 1 | 20 | 30 | Pass | 524 ms load; 24.5 ms max frame |
| 1 | 100 | 200 | Pass | 323 ms load; 25.7 ms max frame |
| 1 | 500 | 1,000 | Pass | 1,056 ms load; 17.1 ms max frame |
| 1 | 1,000 | 2,000 | Pass | 3,037 ms load; 9.3 ms max frame |
| 2 | 500 | 1,000 | Pass | 1,102 ms p95 load; 16.8 ms max frame |
| 2 | 1,000 | 2,000 | Fail | 550 ms frame; 534 ms long task |
| 5 | 500 | 1,000 | Pass | 4,336 ms p95 load; 34 ms max frame |
| 10 | 100 | 200 | Fail | A node did not attach within 30 seconds |
| 10 | 500 | 1,000 | Fail | A node did not attach within 30 seconds |
Table 1. Initial release-build results. We excluded one run that reached the local login limit. We excluded another run because test setup caused a 263.7-second database checkpoint. We repeated the failures shown above after those conditions cleared.
Problem one: too much server work at startup
Ten people still failed on a small Canvas. The server logs showed why. Login, account, Canvas, and notification requests all competed for database connections. Some notification requests took 40.5 seconds. Some requests timed out.
Show the slow server logs
{
"route": "/api/workspaces/[workspaceId]/canvases/[canvasId]",
"operation": "auth.session.read",
"duration_ms": 8368,
"message": "slow_db_query"
}
{
"route": "/api/workspaces/[workspaceId]/canvases/[canvasId]",
"status": 500,
"error": "timeout exceeded when trying to connect"
}
{
"route": "/api/workspaces/[workspaceId]/notifications",
"status": 200,
"duration_ms": 20438,
"message": "slow_route"
}
The browsers and server also shared one computer. Ten browsers could take so much CPU that the server could not collect finished database work quickly enough.
We gave the browsers a lower CPU priority during the 10-person tests. The pass rules stayed the same. A larger test system should run the browsers and server on separate computers.
Fix one: use fewer database connections
Notifications used one database connection for the list and another for the unread count. We changed them to use one query and one connection.
Show the database query
with notification_page as (
select *
from workspace_notifications
where workspace_id = $1
and recipient_member_id = $2
order by created_at desc, notification_id asc
limit $3
)
select
coalesce(jsonb_agg(to_jsonb(notification_page)), '[]') as items,
(select count(*) from workspace_notifications
where workspace_id = $1
and recipient_member_id = $2
and status = 'unread') as unread_count
from notification_page;
Live notification updates now wait until the first notification list has loaded. This removes another burst of login and database work while the Canvas is opening.
Problem two: moving one node measured every node
The server fix did not solve the slow drag. We traced one complete move: press, drag, release, and settle. Releasing one node made the browser measure all 1,000 nodes again.
Show the browser trace
{
"phase": "pointer-up",
"maxFrameDelayMs": 2350,
"maxLongTaskMs": 1974,
"droppedFrames": 142,
"canvasBoardCommits": 5
}
A Chromium CPU profile found 608.6 milliseconds in
querySelector while React Flow measured node handles.
Profiling adds overhead, so this number located the slow path. It
was not used as the final benchmark.
Fix two: keep local drag work local
React Flow already knows where a node is during a local drag. Midflight now trusts that local state and records the change in the background. It still performs a full update for remote changes and changes to the Canvas structure.
What Chrome measured
We also read Chrome's built-in performance counters. This was one fresh browser page on the release build. We waited for the Canvas to settle, moved one node in 120 small steps, and waited one more second.
Show the full Chrome counters
- Chrome reported two layout passes and 115 style recalculations. Style recalculation used 27.3 milliseconds.
- Main-thread tasks used 554.7 milliseconds in total. This is a sum across the whole drag window, not one long task.
- The JavaScript heap used 33.9 MiB from a 157.6 MiB allocated heap after the drag.
- React Flow kept 156 Canvas nodes and 364 edges in the DOM for this viewport. The stored graph still held 1,000 nodes and 2,000 edges.
Source: Chrome DevTools Protocol
Performance.getMetrics. This is one representative
single-page sample, not a multi-user memory benchmark.
The final test passed
The largest test passed. All 10 people joined the Canvas. The final dense drag had no long task, and its slowest frame took 33.4 milliseconds.
In the table below, p95 means 95 out of 100 measurements were faster than the number shown.
| Workload | Result | Load p95 | Frame p95 | Max frame | Max long-task total |
|---|---|---|---|---|---|
| 1 user, 1,000 / 2,000 drag | Pass | — | 9.1 ms | 33.4 ms | 0 ms |
| 10 users, 100 / 200 | Pass | 14.1 s | 9.3 ms | 17.4 ms | 0 ms |
| 10 users, 500 / 1,000 | Pass | 16.6 s | 16.6 ms | 150 ms | 350 ms |
| 10 users, 1,000 / 2,000 | Pass | 19.7 s | 16.7 ms | 92.5 ms | 205 ms |
Table 2. Final local standalone-release results. Frame and long-task columns report the worst browser in each simultaneous-user run. Every 10-user run reached 10 presence users with no captured browser or Canvas API errors.
Production telemetry showed a gap
We also took a read-only production snapshot on August 10, 2026. This was separate from the local load test. Loki had Midflight logs, but Prometheus and Jaeger had no current Midflight application data.
The reason was clear in the deployed configuration: telemetry, export, metrics, traces, and OpenTelemetry logs were all disabled. Loki still received container logs from standard output. This means we cannot connect the local browser result to a production trace or Prometheus time series yet.
Show the Prometheus, Jaeger, and Loki checks
| Source | Window | Check | Result |
|---|---|---|---|
| Prometheus | Current | Midflight application metric series | 0 |
| Jaeger | Current | Midflight services | 0 |
| Jaeger | 24 hours | midflight-web traces |
0 |
| Loki | 24 hours | Web log lines | 33 |
| Loki | 24 hours | Error log lines | 1 |
| Loki | 24 hours | Slow database log lines | 5 |
| Loki | 24 hours | Slow route or database-pool timeout lines | 0 |
Snapshot time: 2026-08-10 23:58 JST. Counts are aggregate and do not include customer content, identifiers, or raw log messages.
What this test proves
- The release build passed our local rules with 10 people, 1,000 nodes, and 2,000 connections.
- The final test removed the long pause after moving a node.
- This was one local computer. It does not prove production capacity or network speed around the world.
Show the full evidence limits
The local benchmark did not export a distributed request trace. We used browser phase traces, a Chromium CPU profile, server timing logs, PostgreSQL activity, Playwright samples, and Chrome performance counters. The production telemetry snapshot above is separate from that benchmark.
We collected JavaScript memory values but left them out of the result table. Chromium could not isolate the memory of each page well enough during the multi-page test.
The test does not set a service-level objective or prove database sizing for production.
Why the steps matter
A single large test would only tell us that Canvas was slow. Smaller steps showed us two different causes. Fewer people revealed the browser problem. A smaller Canvas revealed the server problem.
The test now stays in Midflight. It starts small, grows one step at a time, and stops at the first failure. The next investigation will begin with a clear number, not only “Canvas feels slow.”