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.

50 ms 95% of frame gaps had to stay below this
250 ms the slowest frame had to stay below this
500 ms maximum blocked time in each browser
30 s every person had to finish loading by then
Canvas performance staircase The test advances from one user and a small graph through two and five users to ten users and a graph with one thousand nodes and two thousand edges. Each tier must pass before the next tier runs. 1 user 20 → 1,000 nodes 2 users 500 → 1,000 nodes 5 users 500 nodes 10 users 1,000 / 2,000 Increase users and graph density only after the previous tier passes
Figure 1. The staircase keeps a failure attributable to a specific increase in users or graph density.

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.

Page-start request fanout before and after the fix Before the fix, Canvas and notification requests competed for repeated authentication, account, list, count, and event-stream work. After the fix, notification list and unread count use one query and the event stream opens after the first list response. Before After Canvas request Auth + account Canvas query Notifications Auth + account List query Unread query + SSE Canvas request Auth + account Canvas query Notifications Auth + account List + unread Open SSE after first fetch
Figure 2. The notification path now uses one list-and-count query and starts realtime delivery after the initial fetch.

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.

Dense Canvas drag path before and after the fix Before the fix, pointer up caused a parent state update, a full React Flow node synchronization, and handle remeasurement. After the fix, React Flow keeps its local drag state while Midflight records the operation; full synchronization remains for remote and structural updates. Before: 2.35-second frame After: 33.4-millisecond max frame Pointer up Parent state update Full setNodes synchronization Measure 1,000 nodes and their handles Pointer up Record operation in a transition Keep React Flow local state Full sync only for remote/structural work
Figure 3. Local position and selection updates stay inside React Flow. Remote, hydration, size, and structural changes still use the authoritative full synchronization path.

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.

9,669 DOM elements on the full page after the drag
33.9 MiB JavaScript heap in use after the drag
117.3 ms JavaScript work during the measured drag window
0.104 ms layout work during the same window
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.

70× lower maximum drag frame, 2,350 ms to 33.4 ms
0 long tasks in the final dense-drag sample
10 / 10 users present on the largest final Canvas
16 focused regression tests passing

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.

0 current Midflight application series in Prometheus
0 Midflight services or traces visible in Jaeger
33 Midflight web log lines in Loki over 24 hours
5 slow database log lines in the same 24 hours

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.”