<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Przemyslaw Kalka</title>
    <description>The latest articles on DEV Community by Przemyslaw Kalka (@przemek-k).</description>
    <link>https://dev.to/przemek-k</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3970123%2F59cdc663-b753-4fc3-81f6-10b7520eae20.png</url>
      <title>DEV Community: Przemyslaw Kalka</title>
      <link>https://dev.to/przemek-k</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/przemek-k"/>
    <language>en</language>
    <item>
      <title>How to Profile a Real-Time UI</title>
      <dc:creator>Przemyslaw Kalka</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:33:28 +0000</pubDate>
      <link>https://dev.to/przemek-k/how-to-profile-a-real-time-ui-4fn1</link>
      <guid>https://dev.to/przemek-k/how-to-profile-a-real-time-ui-4fn1</guid>
      <description>&lt;p&gt;The dashboard came up in under a second, and every loading number was green. None of that was the problem. Once the feed went live, with a vol-surface fit recomputing on every tick, the chart began to stutter and lag.&lt;/p&gt;

&lt;p&gt;That is a different kind of problem, and you find it a different way. A load audit runs once and hands you a score; this you profile, by driving the UI under its real workload and reading the trace. The hard part is knowing what to read. By the end of this post you will know, for each way a real-time UI comes apart under load, exactly where to look in DevTools and which number matters.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Load is not run-time.&lt;/strong&gt; Loading is the cost of coming up; run-time is the cost of staying responsive while the data keeps arriving.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Performance isn't one speed
&lt;/h2&gt;

&lt;p&gt;Inside the run-time regime, "is it fast?" stops having one answer. A live UI is governed by three independent rates that keep competing long after load completes. They are a producer, a transformation, and a presentation; in a browser UI they show up as input, compute, and display, the last being the rate at which the screen itself can present new frames, fixed by the hardware at 60 to 120 Hz. The one rate you set is none of these: it is the cadence at which you commit to the screen, the knob you tune between them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Performance is governed by competing rates, not a single speed.&lt;/strong&gt; The diagnostic unit is not time; it is the relationship between three rates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1yk0579qgpkgeexh476.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu1yk0579qgpkgeexh476.png" alt="Three rates govern a real-time UI, drawn as bars. Input, 50 to 500 Hz, is widest; compute, about 28 Hz with a p99 near 57 ms, is the narrowest, the bottleneck; display, the screen's refresh at 60 to 120 Hz, sits between them. All three are given; the one you set is the commit cadence, about 5 Hz, the knob." width="800" height="341"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Run-time failures are mismatches between competing rates, not a single measure of speed. Compute numbers read off a 5.07 s Performance trace of the demo under a 500 Hz feed, 249 fits; a fast feed genuinely outruns the fit.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Used this way, the model is a procedure, not a taxonomy. Performance debugging is relationship debugging: you are not measuring one speed, you are finding the two rates whose relationship has broken down. The procedure is the same every time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Identify the input rate.&lt;/li&gt;
&lt;li&gt;Identify the compute rate.&lt;/li&gt;
&lt;li&gt;Identify the display rate.&lt;/li&gt;
&lt;li&gt;Find the pair that is out of step.&lt;/li&gt;
&lt;li&gt;Measure that mismatch.&lt;/li&gt;
&lt;li&gt;Narrow the class of likely fixes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rest of this post runs that procedure once per mismatch: every diagnosis below is these six steps applied, with the DevTools surface and the number for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  You profile it, you don't audit it
&lt;/h2&gt;

&lt;p&gt;A page-load audit works because loading is predictable: every user loads the same bytes the same way, so one automated pass characterises it. Run-time is the opposite. The expensive work is triggered by what the user and the feed do, it is specific to your workload, and it sits on no single path. You do not audit it; you drive the actual scenario, open the chain, fire the vol shock, drag the slider, and record a Performance trace while it runs. That is closer to end-to-end testing than to a one-shot audit.&lt;/p&gt;

&lt;p&gt;This is the honest place for Core Web Vitals. They are very good at the regime they were built for, the pay-per-load web where a faster load lifts revenue, and they stop here by design. Even Interaction to Next Paint (INP), the closest of them, times a discrete interaction; a feed that drives a chart on its own fires none. There is no run-time metric in that toolkit because a workload-specific cost generally has to be profiled under representative load, not captured by a single generic pass.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuzon3ho7x92fcoaujiun.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuzon3ho7x92fcoaujiun.png" alt="A Lighthouse Performance report scoring 100, with First Contentful Paint 0.5 s, Largest Contentful Paint 0.8 s, Total Blocking Time 0 ms, Cumulative Layout Shift 0.001 and Speed Index 0.5 s, all green." width="800" height="662"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The load audit on the demo: a perfect Performance score, and silent about run-time. LCP 0.8 s, TBT 0, CLS 0.001. True, and not the question this post asks.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read the three rates directly from the trace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input:&lt;/strong&gt; instrument it, or take the feed's tick rate (a &lt;code&gt;performance.mark&lt;/code&gt; per event). It is a range, not a number: read it during a vol event, not a calm market, or you measure the easy case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute:&lt;/strong&gt; the task's duration on the Main or Worker track, and it is two numbers, not one: the median governs whether you keep up with the feed, the p99 governs the worst frame. Both are ranges too. The same fit runs faster on your dev box than on a trader's locked-down laptop or a VDI session, so a local reading is an optimistic floor, not the number every desk sees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Display:&lt;/strong&gt; the screen's refresh, 60 or 120 Hz, the ceiling your commits live under. Read your own commit and frame cadence against it from the Frames track and React's Profiler; that cadence is the knob, healthy when you throttle it well below the ceiling, broken when the feed drives it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you can see all three, the failures are the gaps between them.&lt;/p&gt;

&lt;p&gt;I will show each gap, and beside it what correct looks like, from &lt;a href="https://demo.oracaus.dev" rel="noopener noreferrer"&gt;demo.oracaus.dev&lt;/a&gt;, which fits a fifty-expiry surface to a streaming chain and stays smooth doing it. Its trace under the worst case is the reference for healthy: a near-idle main thread with the feed running hot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3183eunqlpv0q4fv3v41.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3183eunqlpv0q4fv3v41.png" alt="A Chrome DevTools Performance trace over 5.07 seconds under a 500 Hz feed with a vol shock running. The Summary shows scripting 897 ms, rendering 137 ms, painting 30 ms and system 87 ms against a 5,071 ms total, about a quarter busy. The Frames track below it is green." width="800" height="187"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same UI profiled under a 500 Hz feed with a vol shock running: the main thread is busy about a quarter of the 5.07 s window, idle the rest, and the Frames track is clean. This is the reference for healthy.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The method on one screen
&lt;/h2&gt;

&lt;p&gt;Here is the whole method at a glance. The rest of the post is one row at a time, with the DevTools steps and the healthy trace for each.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You see&lt;/th&gt;
&lt;th&gt;Rates&lt;/th&gt;
&lt;th&gt;Look here&lt;/th&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Likely fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;renders far exceed paints; work thrown away&lt;/td&gt;
&lt;td&gt;input &amp;gt; display&lt;/td&gt;
&lt;td&gt;React Profiler commits; Main track&lt;/td&gt;
&lt;td&gt;commits/sec vs frames painted&lt;/td&gt;
&lt;td&gt;subscribe once, throttle commits below the display&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;data lagging, queue growing&lt;/td&gt;
&lt;td&gt;input &amp;gt; compute&lt;/td&gt;
&lt;td&gt;Worker/Main track; User Timing; queue depth&lt;/td&gt;
&lt;td&gt;compute median vs inter-input interval&lt;/td&gt;
&lt;td&gt;coalesce to the latest input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;output doesn't match the input shown (correctness)&lt;/td&gt;
&lt;td&gt;the tear, not a rate pair&lt;/td&gt;
&lt;td&gt;on screen; coherence check&lt;/td&gt;
&lt;td&gt;does the output's input match the input on screen (timing is only the precondition)&lt;/td&gt;
&lt;td&gt;commit input and output as one snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stutter, dropped frames&lt;/td&gt;
&lt;td&gt;compute per frame &amp;gt; display budget&lt;/td&gt;
&lt;td&gt;Frames track; Main flame; Performance Monitor&lt;/td&gt;
&lt;td&gt;p99 frame time, dropped frames, layouts/sec&lt;/td&gt;
&lt;td&gt;move compute off-thread; compositor, cut redraw, cut allocation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Re-rendering faster than you paint
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The component re-renders on every input event, far more often than the screen repaints. Most of that work is thrown away before anyone sees it. It is worse than waste: those renders run on the main thread, so they help starve the next frame, feeding the dropped-frame failure below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhd3hjggjorqhtv6dvcza.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhd3hjggjorqhtv6dvcza.png" alt="Twelve renders fire at the feed rate, nine of them wasted; only three land on a paint at the slower display rate." width="800" height="271"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Renders fire at the feed rate; only the three that land on a paint survive. The rest is thrown away.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look here.&lt;/strong&gt; Open React DevTools, switch to the Profiler, and record while the feed runs. If it reports that profiling is not supported, the build stripped the instrumentation: profile a development or profiling build instead (for React, the &lt;code&gt;react-dom/profiling&lt;/code&gt; entry, which stays minified and production-representative). A naive component commits on every update, a dense picket fence of bars in the commit timeline; in the Performance panel, the same work packs the Main track between paints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure.&lt;/strong&gt; Commits per second against frames actually painted. If commits run at the feed rate while the screen paints at 60 or fewer, the difference is pure waste.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Healthy.&lt;/strong&gt; Commits track a cadence you choose, not the feed's, and that cadence sits well below the display's 60 to 120 Hz ceiling. Profiled against a 500 Hz feed, the demo commits at a deliberate 5 Hz throttle, not 500, each commit a median 0.1 ms of React work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Likely fix.&lt;/strong&gt; Stop letting the feed set your commit rate: subscribe to the feed once and let the UI commit at a cadence you set, rather than threading every tick through props.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compute falling behind the feed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Results lag the feed, and under load the lag grows. What is on screen is several ticks old, and getting older.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46wl93tl8ljk29e4egm6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46wl93tl8ljk29e4egm6.png" alt="Dense input ticks above four compute blocks, each as wide as three ticks; the first result lands three ticks late and the lag grows." width="800" height="254"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Each fit outlasts several ticks, so its result is stale on arrival; without coalescing, the lag grows.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look here.&lt;/strong&gt; Open the Performance panel, record under load, and find the compute task on the Main track, or the Worker track if you moved it off-thread; read its duration. Bracket it with User Timing, a &lt;code&gt;performance.mark&lt;/code&gt; at the input and a &lt;code&gt;performance.measure&lt;/code&gt; at the result, to put an input-to-result latency on the Timings track. If you queue inputs, instrument the queue depth: a growing queue is the tell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure.&lt;/strong&gt; Compute median against the inter-input interval, not the p99. The backlog grows without bound exactly when the typical fit outlasts the gap between ticks: that is a utilisation fact, governed by the median (or mean), not the tail. You can sit with a p99 above the interval and a median below it, draining the queue on every calm stretch, which is bounded bursty lag, a different failure. Once the median crosses the interval, you cannot keep up tick-for-tick, so either the queue grows without bound or you coalesce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Healthy.&lt;/strong&gt; Input-to-result latency stays bounded and queue depth does not trend upward, because each fit runs against the latest chain with the intermediate ticks coalesced away, not because back-to-back execution keeps up. At a 36 ms median against a 2 ms interval, processing every tick in order diverges without bound; that is the broken case.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2spqskjzo067wgj9b6p5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2spqskjzo067wgj9b6p5.png" alt="The Worker track of a Performance trace: one worker thread packed with back-to-back full-surface fits of near-uniform width, a steady stream with no growing gap between them." width="800" height="169"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Full-surface fits on the worker thread, each a median 36 ms (about 57 ms at p99). The gap stays bounded because each fit runs against the latest chain, intermediate ticks coalesced away, not because the thread keeps up tick-for-tick. Off the main thread, so a fit that outlasts a frame never blocks one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Likely fix.&lt;/strong&gt; Coalesce: absorb the intermediate inputs and compute against the latest. This is backpressure's lossy path, keep the newest and drop the rest, so you stay one result behind instead of falling endlessly further back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The answer that no longer matches the input
&lt;/h2&gt;

&lt;p&gt;You reach this one by fixing the others, and it is the residual they leave behind. Coalesce the backlog away, with no async involved at all, and the one result you do show was still computed during a fit the input moved underneath, so it is paired with an input it never matched. Moving heavy work off the main thread, the fix for the budget failure, only widens that window; it does not create the bug. And you cannot tune your way out: input rate climbs with volatility and compute time climbs on slower hardware, so on a busy feed or a slow machine the compute loses the race, however fast it is on your own box. The answer has to be structural, not a faster fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The output on screen is paired with an input it was never computed from, a frame that was never true. In the demo, the fitted curve pulls off the quotes it was built from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look here.&lt;/strong&gt; Not in the flame chart. You see it on screen, the fitted curve pulling off the quotes it was fit to, or you catch it with a coherence metric. The timing precondition, though, is visible: the compute taking longer than the gap between inputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure.&lt;/strong&gt; A coherence check: does the input the output was computed from match the input on screen. That check, and not any timing statistic, is what makes this its own failure. The timing condition, compute outlasting the gap between inputs, is only the precondition, and it is shared with the backlog failure above; it is not the measure here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Healthy.&lt;/strong&gt; The output on screen always matches the input on screen: every pair is committed from the one snapshot it shares.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Likely fix.&lt;/strong&gt; Commit the input and its output together as one snapshot. This is the one failure the profiler cannot show you; I took it apart in full in &lt;a href="https://oracaus.dev/blog/useeffect-abortcontroller-tears" rel="noopener noreferrer"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A frame that misses its budget
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The visible jank: stutter, dropped frames, a frame rate that sags and spikes. The work for a frame overran the display's budget, 16.7 ms at 60 Hz or 8.3 ms at 120, and the frame missed its slot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look here.&lt;/strong&gt; Open the Performance panel and record under load; the Frames track flags long and dropped frames, and clicking one opens the Main track flame chart showing where the time went, scripting, style recalculation, layout, paint. For a live read, open the Performance Monitor (press Cmd+Shift+P, or Ctrl+Shift+P on Windows, and run "Show Performance Monitor"), then enable CPU usage, Layouts per second, Style recalculations per second, and JS heap size; it plots them live as the feed runs, which a single trace cannot. The Rendering tab's Frame Rendering Stats overlays live frame rate on the page itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure.&lt;/strong&gt; Read the worst frames, your p99, and the dropped-frame count straight from the Frames track; the long tasks (over 50 ms, marked with a red corner) on the Main track; and layouts-per-second live in the Performance Monitor. The mean frame time will look fine; it is the worst frames the user feels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Healthy.&lt;/strong&gt; Frames stay inside budget, the main thread runs no long tasks and sits idle between paints, and the redraw cost stays flat rather than climbing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdk4mol04tsv7iva45pqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdk4mol04tsv7iva45pqz.png" alt="The Chrome Performance Monitor in a clean production build: CPU usage 24.5 percent, JS heap size 86.8 MB, DOM nodes 3,122, Layouts per second 8, Style recalculations per second 8." width="800" height="306"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Under the hot feed the monitor holds steady: the JS heap sawtooths between roughly 50 and 95 MB without trending up, DOM nodes flat near 3,100, CPU around a quarter. Layouts and style recalculations hold in the single digits, peaking near 10 a second, never thrashing or climbing. No memory leak, no node leak.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Likely fix.&lt;/strong&gt; It depends on which part of the frame is heavy. If it is scripting, a synchronous computation blocking the main thread, move it off-thread, usually into a worker; that is the compute failure above, surfacing as jank. If it is the rendering itself: move the animation to the compositor (&lt;a href="https://oracaus.dev/blog/compositor-animation-chart-performance" rel="noopener noreferrer"&gt;covered here&lt;/a&gt;), cut the redraw work or drop to canvas, or kill the allocation the garbage collector is paying for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this comes from
&lt;/h2&gt;

&lt;p&gt;I did not assemble this from blog posts; I learned to read these traces on trading desks, in FX, rates and derivatives, where a frame that lies is a mispriced quote. The pieces are old and not mine alone: &lt;a href="https://nolanlawson.com/2021/02/23/javascript-performance-beyond-bundle-size/" rel="noopener noreferrer"&gt;Nolan Lawson&lt;/a&gt; on main-thread cost, the reactive-streams world on backpressure, the React team on concurrent rendering, the game world on frame pacing. What I am adding is one repeatable procedure for reading them: find the relationship that is breaking down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own UI
&lt;/h2&gt;

&lt;p&gt;You can put this to work in a few minutes, without any of my code. Take a real-time UI of your own, drive its worst scenario, and record a Performance trace while it runs, not while it loads.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Run-time performance is governed by competing rates&lt;/strong&gt;, and a janky real-time UI is a mismatch between two of them. It answers a different class of question from your loading metrics, not a replacement for them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Find the input. Find the compute. Find the display. Find the relationship that is breaking down. That tells you where to look next.&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Cancelled the Stale Request. The Render Still Lied.</title>
      <dc:creator>Przemyslaw Kalka</dc:creator>
      <pubDate>Tue, 23 Jun 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/przemek-k/i-cancelled-the-stale-request-the-render-still-lied-2ph1</link>
      <guid>https://dev.to/przemek-k/i-cancelled-the-stale-request-the-render-still-lied-2ph1</guid>
      <description>&lt;p&gt;The pattern is the correct one. An input changes, a &lt;code&gt;useEffect&lt;/code&gt; fires the async compute, an &lt;code&gt;AbortController&lt;/code&gt; cancels the one still in flight, and &lt;code&gt;setState&lt;/code&gt; commits the result. I had moved the heavy work off the render path, wired all of it, and cancelled cleanly. Under load the chart still drew a shape the data never made. Not stale, not a race I had forgotten to guard: one frame, stitched from two different moments.&lt;/p&gt;

&lt;p&gt;Cancellation had handled the old request. It had never promised that the result I committed was paired with the input I committed it against.&lt;/p&gt;

&lt;p&gt;Three properties get conflated into one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Freshness&lt;/strong&gt;: how recent the output is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancellation&lt;/strong&gt;: whether obsolete work was prevented from committing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coherence&lt;/strong&gt;: whether the frame was ever true as a whole.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They are independent. You can cancel perfectly, render the freshest input, and still commit a frame that was never true.&lt;/p&gt;

&lt;p&gt;Once a derivation runs asynchronously, "showing the right thing" stops meaning the latest result and starts meaning a result paired with the input it was computed from. React's default, where each piece of state updates on its own, quietly breaks that pairing the moment the compute outlasts the gap between inputs. And the rule for what to do when a fresh input lands mid-compute inverts depending on the kind of input: a slider and a market tick want opposite handling. That asymmetry is the part I had not seen written down, and it is the second half of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern you trust
&lt;/h2&gt;

&lt;p&gt;You have shipped this one, and it worked: a typeahead box. The user types, a &lt;code&gt;useEffect&lt;/code&gt; fires a &lt;code&gt;fetch&lt;/code&gt;, an &lt;code&gt;AbortController&lt;/code&gt; cancels the request still in flight, a spinner holds the gap, and the matching results commit. It is correct, and it stays correct for two supports that are easy to stop noticing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The browser enforces the cancel.&lt;/strong&gt; Abort a &lt;code&gt;fetch&lt;/code&gt; and the response never reaches your code, so a stale result cannot surface to be mispaired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The input is an intent.&lt;/strong&gt; When the query moves you genuinely want the work in flight gone, so cancel-and-restart is exactly the right policy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Heavy local compute removes the first support. A live feed removes the second. The rest of this post is those two changes, one at a time, and the one invariant that survives both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first change: heavy local compute
&lt;/h2&gt;

&lt;p&gt;The fix was correct. It also opened this hole, by taking the first of those two supports away.&lt;/p&gt;

&lt;p&gt;Last time, the question was where a re-drawing chart actually spends its time. This post starts one move earlier, at the decision that made the chart async in the first place. A fit, a revaluation, a Monte Carlo that takes longer than a frame cannot run in the render body without freezing the UI, so you move it off the main thread, into a worker, and the interface stays responsive. That is the right call, and the bug only exists because I made it: I had already solved the performance problem, and that is what created the correctness one.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;fetch&lt;/code&gt; the browser would have cancelled for you; a worker will not, unless the compute polls the abort signal mid-flight, which the heavy synchronous work you moved there rarely does. Its result comes back later, and "later" is long enough for the input to have moved. The result lands tagged, in your head, to the input that started it, and gets committed next to whatever the input happens to be now. Nobody wrote a bug. The bug is structural: going async split one operation into "what I asked" and "what came back", and nothing in the default model keeps those two paired when they reach the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirty lines that lie
&lt;/h2&gt;

&lt;p&gt;You do not need a vol surface to see it. Here is the whole phenomenon, and the output is an arithmetic lie you cannot argue with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The async compute (stands in for any heavy derivation)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;asyncSquare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// one heavy compute&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DOMException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aborted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The textbook pattern, done correctly&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;IncoherentSquare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setN&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSq&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;asyncSquare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setSq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// cancels the stale request, correctly&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;² = &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sq&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;n&lt;/code&gt; updates synchronously, the instant you type. &lt;code&gt;sq&lt;/code&gt; updates only when a compute finishes, 120 ms later. They are two independent pieces of state, and the last line composes them as if they moved together. Settle on &lt;code&gt;7&lt;/code&gt; and it reads &lt;code&gt;7² = 49&lt;/code&gt;, correct. Now change to &lt;code&gt;9&lt;/code&gt;: for the next 120 ms the line reads &lt;code&gt;9² = 49&lt;/code&gt;, the new input shown against the previous input's output. Keep the input moving faster than the compute and the gap never closes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;AbortController&lt;/code&gt; is correct, and it is beside the point. There is nothing here for better cancellation to fix: with &lt;code&gt;9&lt;/code&gt; in the box only one compute is in flight, and the lie is on screen the entire time it runs. &lt;code&gt;n&lt;/code&gt; updates now, &lt;code&gt;sq&lt;/code&gt; updates later, and the two are no longer coupled. The bug is not a leftover request to clean up. It is two independent values composed as if they were one. Cancellation is not coherence.&lt;/p&gt;

&lt;p&gt;Call the complete input state observed when a derivation begins its &lt;strong&gt;snapshot&lt;/strong&gt;. The bug then has a one-line name.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A frame that combines inputs from one snapshot with a derived output computed from another.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Coherence and freshness are independent properties. A coherent frame can be old. An incoherent frame can pair your newest input with an output left over from an earlier one. &lt;code&gt;9² = 49&lt;/code&gt; shows the newest input, 9, against a stale answer: the defect is not age, it is the pairing. The rest of this post is a single rule, every emitted frame pairing its input and output from one snapshot, and the two guarantees that hold it up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fokkn6urhe4ofwykj1769.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fokkn6urhe4ofwykj1769.png" alt="Two timelines. In both, the input is 7 then changes to 9 while square(7) is still computing and produces 49. The incoherent commit pairs the live 9 with 7's output and reads 9 squared equals 49. The coherent commit keeps the output paired with its snapshot, 7, and reads 7 squared equals 49." width="799" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

  The compute starts against snapshot 7 and finishes after the input has moved to 9. Pairing the output with the live input is the lie. Pairing it with the snapshot it used is the fix.




&lt;h2&gt;
  
  
  The same bug, with money on it
&lt;/h2&gt;

&lt;p&gt;Squares are unarguable but cheap. Here is the same failure with a cost attached.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://demo.oracaus.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; fits a volatility surface to a streaming option chain, the kind of heavy compute a trading desk runs on the client. Two panels run the identical fit against the identical feed. The top commits on every tick the textbook way. The bottom holds the pairing this post is about.&lt;/p&gt;

&lt;p&gt;Hit the vol shock. It is the square again, now a curve and a cloud of dots: the dots from the latest tick, the curve from a fit a few ticks back, painted as one picture. At the top the curve pulls away from the dots at the strikes that just moved; at the bottom it holds. Every point on the torn curve is real, and as a whole it matches no chain that existed at any single instant. The same failure as the square, now with a position behind it.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/CH8vc6owIUI?start=73"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;It is tempting to read the bottom panel as "more up to date". The fit takes as long as it takes, and within itself the bottom panel is not ahead of anything: its curve and its dots come from the same tick. It does run fresher than the top panel here, because the naive panel additionally lets its fit queue back up and a saturated queue commits older work, but that queue is a separate failure from the tear. The demo shows two numbers per panel to keep the distinction honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coherence error&lt;/strong&gt;: how far the curve on screen sits from the quotes on screen, in the same frame. This is what the pairing fixes. At the top it already runs from tens to hundreds of per cent with the feed calm, and crosses 2000% under the shock. At the bottom it holds around 15% in both regimes, the scale of the fit's own residual against the deliberately noisy quotes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ground-truth fit error&lt;/strong&gt;: how far each panel's fitted parameters sit from the true surface they were fitting. This is the fitter's own error, the irreducible cost of recovering parameters from noisy quotes, and the pairing neither improves nor worsens it. It is the same on both panels, calm and under shock: when the coherence error above explodes, this number does not move. The gulf above is the tear, not a difference in fit quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the claim is narrow and it is measurable: coherence error diverges while&lt;br&gt;
ground-truth fit error stays equal. A coherent frame a few ticks behind is a real past state you can act on. An incoherent frame is a tuple that was never true at any instant. The fix buys you the first and cannot buy you the second.&lt;/p&gt;
&lt;h2&gt;
  
  
  The invariant
&lt;/h2&gt;

&lt;p&gt;State the goal as one sentence and the rest follows:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every emitted frame composes (input, output) from the same snapshot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two guarantees uphold it, and it is worth adding them one at a time, because they fix different failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic commit&lt;/strong&gt; kills the false tuple. Keep the input and the output in one object and commit them together, pairing the output with the input it was actually computed from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Guarantee 1: commit the pair, not two slots&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setPair&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt; &lt;span class="c1"&gt;// n is the input this compute started on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now no frame is internally false. You never see &lt;code&gt;9² = 49&lt;/code&gt; again. A single store, whether &lt;code&gt;useState&lt;/code&gt; with one object or a &lt;code&gt;useReducer&lt;/code&gt;, gives you the atomic commit and no more false tuples. One object is only half the fix. In the timer above, the cleanup truly cancels: &lt;code&gt;clearTimeout&lt;/code&gt; stops the superseded compute, so it never resolves and there is nothing stale to commit. That is why the timer alone never shows the next failure. A worker makes no such promise. Its &lt;code&gt;abort&lt;/code&gt; is a message the worker cannot read until the compute in hand returns, so a synchronous fit you tried to cancel runs to completion and hands you its result anyway, still tagged in your head to an input you have already left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No id tag: the cancelled compute returns anyway
n → 7   start compute A             id 1
n → 9   abort A, start compute B    id 2   (A is mid-compute; the abort waits)
        A returns {in: 7, out: 49}         you are already on 9
        B returns {in: 9, out: 81}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both pairs are true. Commit them as they land and you paint &lt;code&gt;7² = 49&lt;/code&gt; for an input you abandoned before &lt;code&gt;9² = 81&lt;/code&gt; arrives; under a moving slider, a whole backlog of abandoned inputs paints ahead of the latest. The cancel did nothing the worker could honour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity-based admission&lt;/strong&gt; keeps only the current one. Tag each input with a monotonic id and commit only a response whose id is still current:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Guarantee 2: drop a response that is no longer current&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;currentId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// later, when the response lands:&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;currentId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setPair&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first guarantee makes each frame true. The second keeps only the current input's frame. Put exactly that way, the two imply the invariant: every committed pair is internally consistent, and a superseded response is dropped rather than committed, so you never paint a result for an input you have moved past. Each emitted frame draws its input and output from one snapshot, and only the latest is painted.&lt;/p&gt;

&lt;p&gt;Split the work across a pool, or let the compute itself await, and responses can arrive out of order for real; the same id covers that case too. With one synchronous worker the order already holds, and the id still earns its place, because the cancel cannot stop the superseded compute from coming back.&lt;/p&gt;

&lt;p&gt;Here is the minimal correct core, the same repro with both changes in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The minimal coherent core&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CoherentSquare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setN&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPair&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 1: one store, one pair&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;currentId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 2: identity tag for this input&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;asyncSquare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;currentId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setPair&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// drop stale&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;² = &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads &lt;code&gt;2² = 4&lt;/code&gt;, then a beat later &lt;code&gt;7² = 49&lt;/code&gt;, then &lt;code&gt;9² = 81&lt;/code&gt;. Never &lt;code&gt;9² = 49&lt;/code&gt;, and never a result for an input you have left. It lags, and it is always true: coherence, not recency, in two lines of difference.&lt;/p&gt;

&lt;p&gt;Why an identifier, and not the obvious alternatives. A timestamp ties correctness to input timing: it holds until two snapshots fall in the same millisecond, which a counter never has to rule out. Value equality collapses two distinct snapshots that happen to hold equal inputs. No tag at all, leaning on cancellation, cannot tell you whether a late response belongs to the input on screen. A monotonic identifier, unique by construction, survives all three, which is the only reason it is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second change: a live feed
&lt;/h2&gt;

&lt;p&gt;The coherent square is correct, but it has only ever faced one kind of input. Cancelling on every change to &lt;code&gt;n&lt;/code&gt; bakes in an assumption: that &lt;code&gt;n&lt;/code&gt; is an intent, where the latest value is the only one you want, so the work for everything before it should be thrown away. For a slider or a typed field that is exactly right, and you have shipped it before: it is the typeahead reflex, cancel the request for the value the user moved past and wait for the one that matches.&lt;/p&gt;

&lt;p&gt;It is wrong for a market tick, which typeahead never has to face, because a search box only ever emits intents. Cancel and restart on every tick, with a feed faster than the fit, and nothing ever finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cancel-and-restart on every tick, feed faster than the fit
fit needs 60 ms;   a tick lands every 20 ms
t=0    tick1   start fit
t=20   tick2   cancel, restart   (20 ms of work binned)
t=40   tick3   cancel, restart   (binned again)
t=60   tick4   cancel, restart   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fit is always part-way into a compute it will never be allowed to complete, and nothing commits. The opposite policy absorbs the ticks: tick1's fit runs to completion, each later tick is noted as the single pending input, and when the fit lands it commits tick1's result and immediately starts the next fit against tick4, the latest input then waiting. You stay a fit behind, and you always finish.&lt;/p&gt;

&lt;p&gt;So inputs come in two kinds, and the policies are opposites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;streaming absorbs&lt;/strong&gt;: a change mid-compute does not cancel; the in-flight completes against its tagged snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;intent cancels-and-restarts&lt;/strong&gt;: a change mid-compute supersedes the in-flight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mixed inputs are the common case, not the exception. The demo runs both at once: a streaming chain underneath and an intent control on top. There is deliberately no third "always use the latest" kind. If that is what you want, you do not need any of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the things you would reach for do not fix it
&lt;/h2&gt;

&lt;p&gt;This two-way asymmetry is also where the usual tools stop short. Cancellation is the typeahead reflex; like the others it guarantees something real, and none of it is the pairing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Guarantees&lt;/th&gt;
&lt;th&gt;Does not guarantee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cancellation (&lt;code&gt;AbortController&lt;/code&gt;, &lt;code&gt;switchMap&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;request cancellation&lt;/td&gt;
&lt;td&gt;the committed pair&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduling (&lt;code&gt;useDeferredValue&lt;/code&gt;, &lt;code&gt;useTransition&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;render prioritisation&lt;/td&gt;
&lt;td&gt;input-output pairing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching (react-query, SWR)&lt;/td&gt;
&lt;td&gt;server-state freshness&lt;/td&gt;
&lt;td&gt;snapshot coherence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of them sit at the commit boundary, where an input and an output become one frame. Debouncing an intent or throttling a feed only makes collisions rarer; and keeping the input and the output in separate &lt;code&gt;setState&lt;/code&gt;s is the defect itself, not a fix, because the input commits the instant it changes and the output commits when the compute returns, two writes React has no reason to pair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you, and where Oracaus fits
&lt;/h2&gt;

&lt;p&gt;The invariant and the two guarantees are the transferable part. You can hold them by hand for one derivation. &lt;a href="https://www.npmjs.com/package/@oracaus/coherent-derivation" rel="noopener noreferrer"&gt;&lt;code&gt;@oracaus/coherent-derivation&lt;/code&gt;&lt;/a&gt; packages those same two guarantees with the input-kind policies, the tear-free read, and worker and lifecycle handling. One hook, a synthetic-feed demo, no public adopters yet: take it as a worked reference as much as a dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it yourself
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://demo.oracaus.dev" rel="noopener noreferrer"&gt;demo&lt;/a&gt;, trigger the vol shock, and watch the two panels. Then watch the two numbers: coherence error diverges on the naive panel while ground-truth fit error stays level across both. The picture is the hook, the number is the claim, reproduce the number.&lt;/p&gt;

&lt;p&gt;A perf fix that keeps the interface responsive can still hand the user a frame that was never true. That is composition incoherence: cancellation does not prevent it, recency does not describe it. The moment a derivation runs asynchronously, coherence  stops being something the framework hands you and becomes something your architecture  has to keep.&lt;/p&gt;

</description>
      <category>react</category>
      <category>async</category>
      <category>concurrency</category>
      <category>realtime</category>
    </item>
    <item>
      <title>The Animation Wasn't Where the Time Went</title>
      <dc:creator>Przemyslaw Kalka</dc:creator>
      <pubDate>Fri, 05 Jun 2026 17:40:23 +0000</pubDate>
      <link>https://dev.to/przemek-k/the-animation-wasnt-where-the-time-went-5a44</link>
      <guid>https://dev.to/przemek-k/the-animation-wasnt-where-the-time-went-5a44</guid>
      <description>&lt;p&gt;A chart that never holds still has two clocks. New data lands several times a second and the plot redraws: in the rig I will use here, about 200 points and a fitted curve at 5 Hz. Separately from that, the y-axis occasionally rescales when the reader changes what it spans, and I want that rescale to glide rather than jump.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy646tlkg8dxc43apiree.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy646tlkg8dxc43apiree.png" alt="An animation chart with about 200 points and a fitted curve at 5 Hz" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
A chart with 200 dots and a fitted curve.



&lt;p&gt;So two things happen on the same element at once: a transform animation rescale) and a high-frequency content update (the redraw). The instinct is that the hard part is keeping the animation cheap while the redraw keeps firing. I built a rig to measure it, and the instinct pointed at the wrong thing. A transform, handed to the compositor, stays cheap even while the chart redraws at 5 Hz, and the two layout costs I braced for came to nothing. The bill was the redraw itself.&lt;/p&gt;

&lt;p&gt;More precisely than the title lets on: in this workload, a compositor-driven transform stays cheap even while the chart redraws at 5 Hz, and the two layout costs I braced for measured as nothing. The bill was the redraw itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive rescale: interpolate on the main thread
&lt;/h2&gt;

&lt;p&gt;The obvious way to animate a range change is to interpolate it yourself. On each &lt;code&gt;requestAnimationFrame&lt;/code&gt;, compute an intermediate range and re-render the chart at it. It works, and it looks smooth.&lt;/p&gt;

&lt;p&gt;It also runs the entire animation on the main thread. Every frame recomputes the scales and rebuilds the plot, which is the same redraw cost you already pay at 5 Hz, now paid at 60 Hz for the length of the transition. For that window the animation competes for the main thread with the very updates it is animating over.&lt;/p&gt;

&lt;p&gt;Measurements come from a production-built Chrome rig reproducing the workload of the &lt;a href="https://demo.oracaus.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; (about 200 moving nodes and a transform rescale, no CPU throttling). Each figure below is total main-thread time accumulated over a five second trace, not a per-frame or per-second number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5 s window · ~200 nodes · production build · no throttle

                      Scripting   Rendering   Painting   ~main thread
rAF (per-frame)         43 ms       48 ms       16 ms       ~107 ms
WAAPI transform         17 ms       19 ms        4 ms        ~40 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix: a composited transform, on an element that can take it
&lt;/h2&gt;

&lt;p&gt;The rescale is, geometrically, a pure transform. Re-render the content at the &lt;em&gt;new&lt;/em&gt; range immediately, then animate a &lt;code&gt;translateY&lt;/code&gt; and &lt;code&gt;scaleY&lt;/code&gt; from the value that makes the new layout &lt;em&gt;look like&lt;/em&gt; the old range back to identity. Two things have to hold for that to stay off the main thread, and the second is easy to miss.&lt;/p&gt;

&lt;p&gt;First, animate &lt;code&gt;transform&lt;/code&gt; only, and let React commit once. The keyframes touch nothing but &lt;code&gt;transform&lt;/code&gt;; React renders the chart a single time, at the new range, then stays out of the transition. That alone is most of the drop from 107 ms to 40 ms: the main thread stops rebuilding the chart sixty times a second.&lt;/p&gt;

&lt;p&gt;Second, animate an element the compositor will actually promote. Chrome runs transform animations on the compositor for HTML and replaced elements, but not for SVG child elements. Put the animation on an inner &lt;code&gt;&amp;lt;g&amp;gt;&lt;/code&gt; and it falls back to per-frame main-thread layout, silently, with no warning. So the chart is two layers: a static base SVG for the axes and grid, and an interior layer for the dots, curve and cursor, wrapped in an HTML &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. The transform animates the &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// yRange changed. The interior is already re-rendered at the new range;&lt;/span&gt;
&lt;span class="c1"&gt;// animate the wrapper from "looks like the old range" to identity.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newMax&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;newMin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldMax&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;oldMin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;MARGIN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;innerH&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldMax&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;newMax&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldMax&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;oldMin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// `layer` is the HTML wrapper around the interior SVG, pinned over the&lt;/span&gt;
&lt;span class="c1"&gt;// base with `inset-0` and `transform-origin: 0 0`, so the maths is the&lt;/span&gt;
&lt;span class="c1"&gt;// same as it would be in the SVG view-box.&lt;/span&gt;
&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`translateY(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px) scaleY(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sy&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;translateY(0px) scaleY(1)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;easing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cubic-bezier(.4, 0, .2, 1)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;forwards&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxvy5tbharylfhw268o84.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxvy5tbharylfhw268o84.png" alt="The main thread hands the transform keyframes to the compositor, then carries only the 5 Hz redraw" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;
The compositor handoff.



&lt;p&gt;With both in place the main thread hands off once and goes quiet: the two&lt;br&gt;
keyframes interpolate on the compositor's own thread while the main thread carries only the 5 Hz baseline. The picture on screen is identical; what dropped is main-thread work, not a jump in frame rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm it composited; the Animations lane will not tell you.&lt;/strong&gt; This is the step that is easy to skip, because a transform animation shows up in DevTools' Animations lane whether or not it is on the compositor. The real check is the animation event in a trace: its &lt;code&gt;compositeFailed&lt;/code&gt; field reads zero when the animation is composited, and a non-zero bitmask of reasons when it is not. The inner &lt;code&gt;&amp;lt;g&amp;gt;&lt;/code&gt; reports a non-zero value; the HTML wrapper reports zero. Skip the check and it is easy to ship a "compositor" animation that is quietly running on the main thread, costing a little layout on every frame.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1zxclnknldrcxfaxi5f6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1zxclnknldrcxfaxi5f6.png" alt="DevTools performance trace of the rAF approach, main thread busy throughout" width="742" height="173"&gt;&lt;/a&gt;&lt;/p&gt;
rAF: Scripting 43, Rendering 48, Painting 16 ms; ~107 ms on the main thread.



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmzunlrtc9uitx4o32v3p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmzunlrtc9uitx4o32v3p.png" alt="DevTools performance trace of the WAAPI approach, main thread mostly quiet" width="742" height="169"&gt;&lt;/a&gt;&lt;/p&gt;
WAAPI: Scripting 17, Rendering 19, Painting 4 ms; ~40 ms.



&lt;p&gt;The absolute figures are small because this workload is modest. What matters is where the work lands and how it scales: on the main thread, cost grows with redraw frequency and node count. On the compositor, the transform is largely independent of both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layout costs that didn't bite
&lt;/h2&gt;

&lt;p&gt;Both are versions of the same worry: the browser quietly redoing layout, the pass that works out where every element sits, on each one of those 5 Hz frames. On a hot path that is exactly the kind of cost that hides. I built the rig partly to catch them. Neither fired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The forced reflow.&lt;/strong&gt; Layout is normally batched: your code can change a hundred things and the browser works out the new geometry once, just before it paints. But the moment your code &lt;em&gt;reads&lt;/em&gt; a geometry value back (an element's position or size), the browser has to stop and compute layout right then. That synchronous, on-demand layout is a forced reflow, and on a hot path it is a classic offender. A CSS-transition build of the rescale includes one: it commits the start state with a geometry read before the transition begins.&lt;/p&gt;

&lt;p&gt;It does not cost, and the reason is specific. The thing being animated is a&lt;br&gt;
composited &lt;code&gt;transform&lt;/code&gt;, which the browser resolves without touching layout. So when the code forces the read, the transform has invalidated no layout; the read just pulls forward the layout the redraw would do that frame anyway. It reorders work, it does not add it. The two builds come out indistinguishable: 2.5 ms of layout against 2.2 ms over five seconds. A forced reflow only bites when it makes layout run &lt;em&gt;twice&lt;/em&gt; (read, change something that invalidates layout, read again). This one runs it once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs46r8dmaa1zvd3fd11y2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs46r8dmaa1zvd3fd11y2.png" alt="Expected path runs layout twice; with a transform, layout runs once, so the read reorders work rather than adding it" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;
Forced reflow: expected two passes, measured one.



&lt;p&gt;&lt;strong&gt;The moving reference box.&lt;/strong&gt; A transform scales and moves an element around a reference box. Tie that box to the element's own content and, next to a constant redraw, it looks like a trap: a box that wraps content shifting every frame shifts too, and the browser might re-resolve the transform against it each time, layout on every redraw. The HTML wrapper closes this off by construction. Its reference box is the chart rectangle, pinned by &lt;code&gt;inset-0&lt;/code&gt;; it does not track the dots moving inside it, so there is nothing to re-resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the cost actually is
&lt;/h2&gt;

&lt;p&gt;With the animation handed off and the two suspected costs ruled out, the trace points somewhere flat and unglamorous. The dominant per-redraw cost is &lt;code&gt;setAttribute&lt;/code&gt; and Recalculate style, together around a third of main-thread time: mutating a couple of hundred SVG nodes' geometry five times a second, and the style invalidation that follows. It is identical across every build, because it has nothing to do with how the rescale is animated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F03415ahmvmbq98ckssim.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F03415ahmvmbq98ckssim.png" alt="Bottom-up trace with Recalculate style and setAttribute as the top entries" width="799" height="288"&gt;&lt;/a&gt;&lt;/p&gt;
One redraw, bottom-up: setAttribute and Recalculate style dominate, layout ~5% ('Profiling overhead' is DevTools, not the page).



&lt;p&gt;The lever, then, is the redraw, not the transition: fewer nodes, batched attribute writes, or a different rendering substrate such as canvas. Not the animation, which is free once it is on the compositor, and not the reference box, which was never doing what I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;Compositor-only is the right instinct, and it paid off: the rescale belongs on the compositor, and putting it there, on an element that could take it, dropped main-thread work from about 107 ms to 40 ms over the trace. Two qualifications matter as much as the headline. First, "animate &lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt;" is necessary, not sufficient: the element has to be one the compositor will promote, and an SVG child is not, so confirm it with the &lt;code&gt;compositeFailed&lt;/code&gt; flag rather than trusting the Animations lane. Second, the costs I was most sure about either reorder into nothing or never fire, and the one that dominated is the plain one I had stopped looking at: SVG mutation and style recalculation, roughly a third of main-thread time, untouched by anything I did to the animation.&lt;/p&gt;

&lt;p&gt;A plausible mechanism and a measured cost are different things. Only the trace tells you which is which.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;p&gt;Under &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt; the animation runs with &lt;code&gt;duration: 0&lt;/code&gt;, snapping to the final state: the motion is a visual aid, not load-bearing. A rescale that arrives before the last one finishes cancels the in-flight animation and starts fresh, and the animation is cancelled on unmount, which keeps it correct under React's double-mount in development.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API" rel="noopener noreferrer"&gt;MDN Web Animations API reference&lt;/a&gt; covers the &lt;code&gt;Element.animate&lt;/code&gt; handoff used here.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>react</category>
      <category>svg</category>
      <category>rendering</category>
    </item>
  </channel>
</rss>
