Skip to content

fix(windows): make a resize deliver frames at the new size - #323

Draft
jhodges10 wants to merge 6 commits into
vercel-labs:mainfrom
jhodges10:perf/windows-modal-resize-frames
Draft

fix(windows): make a resize deliver frames at the new size#323
jhodges10 wants to merge 6 commits into
vercel-labs:mainfrom
jhodges10:perf/windows-modal-resize-frames

Conversation

@jhodges10

@jhodges10 jhodges10 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Six commits that make a Windows canvas actually receive frames while its bounds are changing, and get those frames onto the glass. Host-side only — webview2_host.cpp, its source pins, and four lines of build/app.zig; no renderer files — so it can land independently of the flip-model migration in #330, which it was previously sitting behind.

Rebased onto main at v0.9.3.

Absorbs #324 (now closed): that fix's correct shape depends on whether the timer queue is present, so keeping it as a separate PR meant maintaining two conflicting versions of it.

What was wrong

A geometry change was not a reason to draw. Emissions are scheduled by input, by animation, and by explicit frame requests. The top-level WM_SIZE handler re-arms child surfaces, but only ones that already had an emission pending — so a panel that happened to be idle when the window (or a dock divider) moved never got a frame event carrying its new size. It kept presenting the packet it rendered for the old bounds until the pointer wandered in and woke it for something unrelated. On a 20-surface app, of the seven panels whose bounds moved in a divider drag, one re-rendered and six waited. Which panels fall on which side is pure accident of what happened to be animating.

That same re-arm cancels frames during a drag. It exists for restore-from-minimize, where a heartbeat-paced deadline can be parked up to a second out and superseding it returns full cadence. But WM_SIZE also arrives on every step of a live resize, where the pending emission is already grid-paced and already nearly due — re-arming there discards a frame that was about to fire and restarts its wait. Measured on a 165 Hz desktop over a ~10 s drag: 2,639 deadlines armed, 445 fired (17%). The window painted 2,250 times against 215 presents, so roughly nine of every ten frames on screen were the previous frame stretched to the new size. It hides well, because a scaled last-good frame is a convincing stand-in for a laid-out one.

And the deadline itself was coarse. A nominal 16.67 ms SetTimer is commonly rounded to the legacy timer grid, and WM_TIMER is only generated after higher-priority input has drained — so a high-rate trackpad or wheel could hold a due frame pending for hundreds of milliseconds.

A rendered frame then waited on WM_PAINT that never came. Presenting a packet invalidated the surface and left the blit to WM_PAINT, which Windows synthesizes only once the message queue drains — which is exactly what a resize prevents. On a 20-surface app, 4,570 renders reached the glass 338 times: the rest were correct frames overwritten before anyone saw them. This is the other half of "some panels resize live, others wait for the mouse", and it survives every scheduling fix above, because the frame was emitted and was rendered.

And the relayout rebuilt Z-order on every view, every step. native_sdk_windows_update_view reordered the window's whole child stack whenever a layer was supplied, not when it changed — and a shell relayout supplies one per view per WM_SIZE. Measured at ~300 SetWindowPos calls per resize step, rebuilding an order that already existed, and the single dominant cost of a live resize.

The commits

a1ffe867 Arm emissions with CreateTimerQueueTimer and deliver them as a posted kGpuEmitMessage, with a process-owned high-resolution waitable timer following the earliest deadline. A generation counter fences a re-arm against a callback already in flight.
c518b0dc Re-point two source pins at that shape. Without this the commit above fails its own tests — they still asserted KillTimer(hwnd, kGpuEmitTimerId); and if (wparam == kGpuEmitTimerId), neither of which survives the change.
e5b5058f Record which cadence a deadline was placed on, and supersede only a parked heartbeat one. The show/policy-hidden re-arm is deliberately untouched: it runs once on a real occlusion transition, not once per message.
53530d43 Schedule from the surface's own WM_SIZE, with a forced full repaint — the runtime plans an idle frame for an unchanged scene, and a resize changes the viewport, not the scene. Gated on the geometry actually changing, so a stream of WM_SIZE cannot hold a full-rate loop open.
6cbb6798 Rebuild child Z-order only when a layer actually changes. A supplied layer is not a changed layer.
2abc44ad UpdateWindow the surface after a present that rendered, so the blit happens now instead of waiting for a WM_PAINT the resize is starving.

The last two are new since the previous revision of this PR, and are the reason a resize now reaches the screen rather than merely being scheduled.

Results

Same 20-surface application, 240-step driven sweep, before → after the two new commits:

before after
resize step ~51 ms ~3.7 ms
sustained rate 14.8 Hz ~260 Hz (210–299 across runs)
SetWindowPos p50 3.5 ms
renders that never reached the glass 92.6% 0%

Read a single sweep as noise: the same binary measured six times in one sitting ranged 210–299 Hz. Only interleaved medians mean anything on this workload.

Validation

Full suite on Windows, against pristine main at the same commit as the control:

steps tests failed
main b25cefe 478/481 3277/3333 4
this branch 478/481 3278/3334 4 — identical set

Net +1 passing test, no new failures. The four are pre-existing on main on a Windows host; two of them are macOS DMG packaging tests that cannot pass there at all.

Worth stating plainly: this is not observable on the SDK's own examples. They are single-surface and light enough that the old scheduling kept up; the numbers above come from a 20-surface application. tools/gpu-image-fixture (added in #330) does not help here either — it prices texture-cache cost, not frame scheduling.

Not included

The instrumentation that found the last two commits is absent from both this PR and #330, because it needs a piece of each: a wmsize profile line splitting a resize step into the synchronous runtime callback, the WebView2 frame apply, and host work, plus how many view frames the relayout touched and how many actually moved a window. It writes through a seam into #330's GpuProfileLog, and it instruments the WM_SIZE handler this PR rewrites — so it cannot compile against either branch alone. Happy to send it as a follow-up once both land.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@jhodges10 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@vercel vercel Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Pointer button up/down events are emitted immediately while queued (coalesced) pointer motion/scroll is only flushed on the next frame, so a fast click-drag-release within one frame delivers a stale drag/move sample AFTER the pointer-up/down.

Fix on Vercel

@jhodges10
jhodges10 force-pushed the perf/windows-modal-resize-frames branch from 6d373a1 to 2976afb Compare August 12, 2026 17:22
@jhodges10 jhodges10 changed the title perf(windows): drive gpu frames off a high-resolution timer queue fix(windows): make a resize deliver frames at the new size Aug 12, 2026
@jhodges10
jhodges10 marked this pull request as draft August 13, 2026 00:09
@jhodges10
jhodges10 marked this pull request as ready for review August 13, 2026 06:12
@jhodges10
jhodges10 marked this pull request as draft August 17, 2026 21:51
jhodges10 and others added 6 commits August 17, 2026 14:59
Windows synthesizes WM_TIMER only when the message queue is empty. The
one-shot emit timer therefore starves under exactly the load that needs
frames most, and vercel-labs#313's post-dispatch drain cannot reach it during a
user-driven move/size drag: DefWindowProc runs its own modal message
pump there, so the run loop — and both of the wake paths it owns, the
waitable timer and the drain — stay parked for the whole drag.

Schedule each deadline on a timer queue whose callback only posts
kGpuEmitMessage. A posted message is an ordinary queued message that
every pump delivers, including the modal one, and no queue-empty rule
gates it. The callback does nothing else, so all app and runtime work
stays on the UI thread, and a generation stamp lets a re-arm discard the
message a superseded deadline already posted.

Alongside that, the pieces the new cadence needs to be real:

- Hold 1 ms system timer resolution for the loop's lifetime. Every
  pacing primitive here quantizes to the system timer, and the default
  ~15.6 ms granularity caps a 240 Hz grid near 64 Hz. Needs winmm.
- Derive the frame interval from the monitor carrying the surface
  instead of a hardcoded 16.67 ms, memoized against its HMONITOR.
- Coalesce pointer motion (latest-wins) and wheel deltas (accumulated)
  to one flush per frame, so an input storm cannot outrun the grid.
- Set WS_CLIPCHILDREN on top-level windows and gpu-surface containers.
  Without it a parent repaint paints COLOR_WINDOW straight over child
  HWNDs, which reads as white strobing over a canvas mid-drag.
- Coalesce WM_MOVE across the modal loop. A pure move changes no client
  size, but each one drove a full shell relayout AND a synchronous
  window-state file rewrite, hundreds of times a second during a drag.
  The settled frame emits once on WM_EXITSIZEMOVE.

Measured on a 165 Hz Windows desktop, retained path, dragging a canvas
window: 1.82 ms/frame at 1037x775 and 2.15 ms/frame at 3053x1175 — 4.5x
the pixels for 18% more cost, worst single frame 2.7 ms.

This overlaps vercel-labs#313 deliberately rather than replacing it. That change
fixed the same starvation for the ordinary loop, where draining after
each dispatch is sufficient; it cannot fix the modal loop, which never
returns to the loop that drains. The drain still runs and still earns
its keep — the two wakes are complementary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two source-pinning tests still asserted the WM_TIMER scheduling this
change replaces, so they fail on it: one looked for
`KillTimer(hwnd, kGpuEmitTimerId);` in the drain helper, the other for
`if (wparam == kGpuEmitTimerId)` as the emit entry point. Neither string
survives arming emissions with CreateTimerQueueTimer and delivering them
as kGpuEmitMessage.

Re-point both at the shape that actually ships. The drain helper retires
a deadline with one call, cancelGpuSurfaceFrameEmission, which also bumps
the generation and clears the scheduled flag, so the old kill-then-clear
ordering pair collapses into retire-before-emit. The emit handler gains
the assertion that matters for a threaded timer: it fences on the
generation before touching anything, because a callback that raced a
cancellation carries a stale one and must drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The top-level WM_SIZE handler re-arms every child surface's pending
emission. That exists for restore-from-minimize, where a heartbeat-paced
deadline can be parked up to a second out and superseding it returns
full cadence without dropping a beat.

But WM_SIZE also arrives on every step of a live resize drag, where the
pending emission is already grid-paced and already nearly due. Re-arming
it there discards a frame that was about to fire and restarts its wait,
so a drag whose steps outpace the frame interval keeps resetting the
deadline just before it lands.

Measured on a 165 Hz desktop, dragging a canvas window for ~10 s:
2,639 deadlines were armed and only 445 fired — 17%. The window painted
2,250 times against 215 presents, so roughly nine of every ten frames
on screen were the previous frame stretched to the new size rather than
content laid out at that size. It looks plausible, because scaling the
last good frame is a convincing stand-in, which is why this hides.

Record the pacing interval a deadline was scheduled against and
supersede only a parked heartbeat one. The reveal path this was written
for still works; a drag now lets its due frames fire.

The show/policy-hidden reveal at the other re-arm site is left alone: it
runs once on a real occlusion transition, not per message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A geometry change was not a reason to draw. Emissions are scheduled by
input, by animation, and by explicit frame requests; the top-level
WM_SIZE handler re-arms child surfaces, but only ones that ALREADY had
an emission pending. A panel that happened to be idle when the window or
a dock divider moved therefore never received a frame event carrying its
new size: it kept presenting the packet it had rendered for the old
bounds, and repaired itself only when the pointer wandered in and woke
it for unrelated reasons.

That is the split a user sees as "some panels resize live, others wait
for the mouse" — which panels depends on nothing but whether each
happened to be animating at the moment of the drag.

Schedule a frame from the surface's own WM_SIZE, gated on
syncGpuSurfaceGeometry reporting a real change so an unchanged message
cannot hold a frame loop open. The repaint has to be forced: the runtime
plans an idle frame for an unchanged scene, and a resize does not change
the scene, only the viewport it is laid out against. AppKit already
forces one across its view-frame and backing-scale transitions; this is
the Win32 half of the same contract.

Cost is bounded by the frame grid rather than the message rate — a drag
delivers WM_SIZE per mouse step and all of them fold into the single
in-flight emission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`native_sdk_windows_update_view` keyed its Z-order rebuild off `has_layer`,
which means "the caller supplied a layer", not "the layer changed". The
shell relayout supplies both a frame and a layer for every view on every
WM_SIZE, so every one of those calls ran reorderWindowChildren — which
collects every child of the window, sorts them, and calls SetWindowPos on
each. Measured on a twenty-surface app: ~300 Z-order calls per resize step,
every one of them rebuilding the order that already existed, because layers
do not change while a window is being dragged.

It also explains a result that made no sense on its own. A downstream app
that owns its panel geometry nulls the frame out of the relayout patch, so
eleven of fifteen views did no frame work at all — and still cost ~1.8 ms
each. The frame was the half being skipped; the layer was the half doing
the damage.

Compare the supplied layer against the stored one and reorder only on a real
change. Creation still reorders through its own path, so nothing that
depends on a fresh view landing in the right order changes.

Windows-only. Per resize step, on that same app:

    WM_SIZE handler   p50  51.41 ms -> 0.37 ms
    applyShellViews   p50  51.39 ms -> 0.40 ms
    SetWindowPos      p50  55.15 ms -> 3.0 - 4.0 ms

The median collapses; the tail does not follow it yet. Throughput over a
240-step sweep still scatters between roughly 150 and 320 Hz run to run,
because a second defect is now the one setting the spread — see the next
commit, which is what makes delivery deterministic.

One caveat worth stating: reordering on every update was, accidentally,
continuously repairing Z-order. Anything that relied on that repair rather
than on getting its layer right will now show it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After rendering a packet the host invalidated the surface and returned,
leaving the blit to WM_PAINT. But Windows synthesizes WM_PAINT only when the
message queue has nothing else to deliver, and a resize floods that queue —
so the paint is starved exactly when frames matter most. Measured on a
twenty-surface app mid-drag: 4570 renders produced 338 paints. Every surface
was affected, from 4.5:1 to 37:1.

The rendered frames were not redundant — each (surface, sequence) rendered
exactly once — so this was not wasted re-rendering. It was the opposite:
correct frames, laid out for the size the window had just become, overwritten
by the next render before anything put them on the glass. What the user saw
instead was the previous frame stretched, which is the long-standing "some
panels resize live, others wait for the mouse" symptom. Which panels lag was
never the interesting part; almost none of them were arriving.

UpdateWindow dispatches the pending WM_PAINT straight to the window
procedure. The frame is already in the target by this point, so this only
spends the blit that makes it count, and it no-ops when the update region is
empty — a render that changed nothing still costs nothing.

    unpainted renders   92.6% -> 0%
    paints              338 -> 4742 (over 4560 renders; the surplus is
                        OS-initiated repaints, which were always there)

The ~0.13 ms per blit across ~16 surfaces a step is real work that was not
being done before, so a cost was expected. It does not show up, because what
it buys back is larger: leaving the blit to the queue meant it landed
whenever the queue happened to drain, and that scatter WAS the tail. Over a
240-step sweep, without this change and with it:

    without   173.1 / 153.8 / 318.6 Hz     p90  4.20 - 14.56 ms
    with      280.3 / 274.3 / 282.3 /      p90  4.73 -  4.95 ms
              275.9 Hz

Same median, an order of magnitude less spread. Deterministic delivery is the
point; the throughput is a side effect of no longer stalling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhodges10
jhodges10 force-pushed the perf/windows-modal-resize-frames branch from 43f4d06 to 2abc44a Compare August 17, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant