close

Infrequently Noted

Alex Russell on browsers, standards, and the process of progress.

The Wicked Reason Removing Code Beats Better Scheduling

A response to Marko Ilić

My colleague Marko Ilić has published an insightful piece on how to schedule work on the critical path, and I recommend reading it before proceeding here.

...and welcome back.

As you likely anticipated, Marko's beautifully presented post kicked off healthy discussions around the office. Since we almost always see eye-to-eye, it seemed interesting to surface some of that discussion here. With his permission, what follows are expanded versions of some points I posed in reply.

There's no daylight between our positions on code reduction: sending and running less is always ideal.

Where we differ, perhaps, is the priority teams should assign to scheduling vs. code removal in their performance remediation efforts.

While it may be hard to remove code, my view is that it is generally not much harder than re-ordering. Why? The primary cost of both code removal and scheduling interventions is the investment to deeply understand page behaviours. This presents a narrative challenge to the proposition that scheduling is an easier fix, as both approaches share the largest cost. Ergo, changes to resource ordering cannot be assumed cheaper or easier.

At the team level, the consequences of restructuring are harder to reason about than direct removal. The bytes that are only deferred still land on the main thread, potentially delaying above-the-fold resources in ways that only become visible in the tail of the connection quality curve. Worse, JS resources that are fetched late generate heavy "thuds" when residual allocations from background compilation arrive on the main thread. These stalls show up in INP data, but can be maddening to track down due to their stochastic relationship with other resources and the unpredictable scheduling of main-thread tasks in the real world.

Explaining these impacts is an ongoing challenge. Working across teams to preserve carefully constructed code ordering requires exquisite regression-prevention discipline. This presents an elevated degree of operational difficulty vs. code removal, where simple bundle size clamps can help to maintain gains.

From the geosynchronous perspective, it becomes easier to see that reordering exacerbates coordination challenges, and per Fred Brooks, those communication effects dominate the shape and quality of the pages a team delivers.

In large enough teams and codebases, creating and maintaining an optimal ordering is a question of global priorities that can only be elucidated by product owners. As they tend not to be the line engineers, their goals will be translated to code in ways they will not fully understand, and the deep implications of that translation process may only become clear in retrospect. It was ever thus in the pin factory.

Goals may also be communicated in different intensities and formats, and change over time. Worse, teams often misidentify their targets. Many groups I've worked with have confidently asserted that "everyone has 5G" or "everyone's on fast devices," despite neither their own dashboards nor the global baseline supporting that presumption. Teams in this situation can easily make incorrect assumptions about the network, CPU, and memory limits of users at the margins. Getting in touch with those limits is straightforward for interested managers, and it's a big part of climbing the Performance Management Maturity ladder. Leaders working through this landscape for the first time are frequently shocked to find just how far out-of-the-money their products have become.

Regardless, many teams build to (or past) it blithely, then spend tremendous amounts of effort re-ordering code without achieving durable gains or increasing TAM. From this market-centric view, removing code has the benefit of making the system more resilient to changing conditions, including shifts in user population due to product growth. This makes code removal particularly potent under uncertainty about user populations, which is where nearly every web project begins life.

Systems that rely on scheduling to assure reasonable performance are brittle. Reordering work to appear responsive for one user journey can help, but such systems have a comparatively harder time adapting to changing needs — something Marko's examples work through, but with only one alternative explored. In reality, products that need this level of attention face an evolving field of possibilities across an expanding range of users and devices, while an increasing stable of features contend for priority.

Teams hoping for performance headroom through scheduling can also reduce their agility through use-case overfitting. Latency concerns become a straitjacket when multiple critical user journeys funnel through a single chokepoint that must balance the needs of all. This should bias remediation toward removing code and breaking up experiences MPA-style until a team is far into the tail of diminishing returns.

Not only will those improvements prove more durable and easier to defend, but smaller systems are also easier to reason about. Code size reductions set the stage for more effective scheduling fixes when a team is ready to switch approaches from a position of confidence.

An underappreciated headache of systems drawn taut around available bandwidth and CPU constraints is that they aren't just difficult for engineers to wrangle; they also appear brittle to managers.

Busy EMs and PMs experience systems in this state as teetering on the edge of usability while soaking up huge amounts for "optimisation." Managers can even end up in learned helplessness as regressions crop up in curious parts of the experience with each seemingly unrelated change. The interplay of browser scheduling, network conditions, and device constraints is extremely fluid, making scheduling fixes the less bankable alternative, especially at page load time. Teams that spend the same quantum of effort eliminating code, by contrast, have more wiggle room and can be more agile over time. Managers that buy into the narrative that reordering is easier than removal deal their future selves a losing hand.

Ceteris paribus, the level of difficulty from an enforcement, regression-prevention, and span-of-control perspective is higher for re-ordering vs. code removal, and savvy managers should always favour investments that will cut code or move it to the server, rather than pushing peas around the plate over the wire or on the client.

A particularly grim failure mode of scheduling as the go-to performance remediation approach plays out in large organisations through competitive use-case devaluation. Individual feature owners advocate for their code to be loaded early, implicitly deferring or delaying code required for other user journeys. This interacts poorly with haunted SPA architectures that force code-loading priorities to play out over an unknowably slow or laggy network and onto a device whose memory and CPU properties are a mystery. In the tail of the distribution, SPA architectures assure that every choice to fill the channel with Team A's code harms Team B's experience.

Bun fights inevitably break out as a degraded commons raises the stakes on every team, backing managers into increasingly strident advocacy for their feature. Because engineering teams in this situation are generally not imposing code size budgets (thanks in part to the mistaken belief ordering will save the day), the consequences for de-prioritisation grow over time. The negative feedback loop is obvious. The notion that scheduling is easier than code removal steers the product into a coffin corner sure as day follows night.

But it's even worse than it sounds.

Because the implications of deferring other code may be hard to reason about, devaluation of a product's core experiences can spiral, unopposed, for years. In this twist on the general failure mode, "special interests" degrade an undefended commons. Thanks to compilers and code motion in "modern" frontend codebases, order-dependent optimisation approaches and "preloading" hacks can hide in plain sight, ballooning costs for all users as seemingly innocuous library code is added to XXXXXX-common.js and YYYYYY-vendor.js without opposition.

A common attribute of low Performance Management Maturity organisations is an absence of processes to push back on this sort of thing. The result is a Gordian Knot that only ever grows more tangled. The linchpin is not doubling down on code ordering, but a decision to get wire size under control.

An unreasonably effective down payment on this commitment is to evict all "preloading" and "deferred loading" systems from a product, size it up as it stands, then force trims. This isn't always the right approach (consulting engineers should always be trace-driven), but for teams that have followed the logic of preloading down the primrose path, it's a bracing corrective that creates accountability through visibility. From that point, "we don't do that here" must become the manager's mantra whenever a feature PM repeats discredited paeans to scheduling, otherwise the product will end up back in the drink.

Unlike removed code, the impact of reordered code is not easy to reason about. Innocuous-looking scheduling changes like loading="lazy" for <img> and <video> can go horribly wrong. Without system knowledge, infrastructure, and culture to support investigations, it's much more difficult to know if work reordering improves end-user perceived latency.

All of this is to take nothing away from the thoughtful technical advice in Marko's post. We can dramatically improve critical-path loading and responsiveness of experiences by better ordering resources, and we should strive to only do work proportional to what's currently on-screen, above the fold.

But that set of opportunities should be treated by engineers and managers as special occasion food. While both approaches can, in theory, yield similar gains per hour of effort, scheduling changes only pan out in systems that already have strict size budgeting, regression prevention, and hawkish launch gates. Unless a team is at least demonstrating the regression prevention attributes of Level 4 Performance Management Maturity, investments in better ordering are a management error. Teams without a strong hand on the rudder and battle-tested early warning systems should, instead, focus almost all of their effort on reducing complexity and code size.

Investments in size reduction also tee up future scheduling improvements, teaching teams about the interplay of system components. Without deep insight into site behaviour, it is challenging to select good opportunities for reordering, and the learning inherent in code reduction invariably helps identify ripe scheduling wins. Likewise, reductions in payload complexity make it simpler and more effective to test the impact of reordering.

In other words, going from decent to excellent performance is a technical challenge, but moving from poor to decent performance is fundamentally a management and culture problem.

Marko's piece is excellent fodder for teams that have a technical problem. I wish that represented more than a sliver of the organisations I've consulted with.

Taken together, these factors give rise to the structure of the advice I dole out to teams looking for help:

  1. Do everything possible to reduce code sent to the client; even the stuff that seems "dirty."
  2. Move work to the server where possible.
  3. Only think about restructuring work when #1 and #2 reach diminishing returns.

Management challenges also give rise to our request that teams enunciate "critical user journeys" and put thought into who their marginal user really is. Just getting leads and PMs to agree on which users shouldn't be excluded due to poor performance and what the most important flows are can create clarity and value, independent of which approach to alleviation a team pursues.

There are a lot of problems that can be ameliorated with better scheduling, but the level of affirmative control a team has to have over a system to effectively execute on those tactics is shockingly uncommon. Therefore, most teams, most of the time, are better off thinking about how to remove code from their bundles.

The Absolute State of Management

It is by no means the gravest linguistic crime of the React epoch,1 but "state management" grates like nails on chalkboard. A Socratic dialogue I keep having about it goes something like this:

me: This UI is far too heavyweight for what it does, have you considered not using React?

them: But how will we do state management without React?

me: What do you mean? <alternatives> are reactive.

them: React manages state in our app.

me: It does? Then what are <various_libraries> for?

them: Oh, those manage state.

me:

The libraries that trigger this "Who's on First?" routine include:

Contrasts sharp enough to cut softened butter.2

Suffice it to say, the ecosystem has not picked a winner. Indeed, I regularly see several of these libraries on a single page. But even one would seem too many, given that we're adding a second "state management" system to a framework that spills considerable documentation ink chest-beating about its state management facilities.

One of the following must be true:

But in the to-and-fro of consulting engineering one can't just say as much, for fear of offence against frameworkist articles of faith.3

Thankfully, this is a blog post, so we can cut to the chase and admit that none of these tools manage state.

They might encapsulate state. They might even filter or transform updates. But they do not, in any meaningful way, manage state, settling for merely propagating notifications about state changes and in some cases facilitating ad-hoc post-processing or caching.4 Conceptually, they are expressively equivalent to event buses and pub/sub systems, which are better recognised as message passing backplanes.5

True state management wrestles with the time dimension, but React-centric "state management" libraries handle time the way C++ does for multithreaded code — which is to say, it's left as an exercise to the reader.6

Systems without a built-in conception of time and ordering are subject to temporal effects. They can even synthesise compound state out of unordered, un-timestamped updates. But they occupy a world of hopes and dreams when it comes to asserting affirmative management of state because managing state means correctly applying changes over time and resolving conflicts.

A large fraction of the problems from overgrown React and Redux attempts at "state management" spring from the same wellstone: updates to the state propagation systems come from a variety of sources, with varying frequency and specificity. Pure propagation systems crumble under the incidental complexity that results from trying to sort out the consequences with only an event bus abstraction. It becomes unclear whose job it is to sort out which updates are "correct", and optimistic UI updates become a liability — or at least a bun fight.

At bottom, this is the same problem as managing data across multiple machines, and managing data over the network is the same as managing it across time that spans from the past, to the current moment, as well as the future.

This insight is important because it allows us to consider what's currently in memory vs. the same app rebooted at some future point without losing our minds. A future local instance of an app is conceptually the same as the app booting on an entirely different computer. Getting crisp on this is the key to throwing out jejune notions of "state management" and replacing them with tools that are fit for purpose.

Systems that manage state — rather than merely propagating or filtering it — have built-in concepts of time and order, and this allows them to handle both offline state and online real-time collaboration naturally. Instead of hoping that everything is happening in some window that we can squint at and call "now," true managers of state mutations have a way to reorder and resolve conflicts, and through that coherence, build a reliable and high-performance view of data changes over time.

You can distinguish them from state propagation tools by looking for variations of vector clocks in their internals. Time and order metadata attached to mutation enables global ordering, and we can't truly manage state without it.7

This conceptual break also provides an answer to our fish-or-fowl question: none of those libraries actually manage state; React included.8

Now that we can more accurately describe what these systems do (and don't do), the phrase "state management" mostly reads as confusion. But hold on to some grace; the folks deploying jumbled terminology come by it honestly.

Systems that really manage application state are not yet popular, and most teams are not familiar with the algorithms and systems that do it credibly. Discussions of CRDTs, Operational Transforms, Prolly Trees, and related approaches to consistency are still rare in the frontend discourse.

Thankfully the gap from upgrading our understanding of theory to deploying better patterns is small. Practical implementations are readily available; each have strengths and weaknesses depending on use-case, but all manage data in a time-aware way:

A beautiful, generative outcome of these systems is that because they think about time and conflicts, they're perfect for building systems that gracefully handle both edges of "now", enabling real-time collaboration seamlessly while simultaneously lighting up offline-first styles of development.9 Some (e.g., Y.js and Replicache) go far enough in this direction to enable true local-first development.

Regardless of which mechanism you adopt, these systems are a sea change. Data models stop being amalgamated hopium, and both offline use and realtime collaboration become possible as a side effect of getting the ordering of data mutation application correct.

This is the future we can have today. All we need to do is admit that "state management" isn't, then decide to do something about it.

FOOTNOTES

  1. By my count the worst linguistic misapprehensions spread by React's know-nothing culture are probably "server-side rendering," "pure function," and "concurrent." Each one camps out on a previously well-studied concept, re-defines it in a less-than-rigorous way, then flicks at the larger (more valuable) idea in order to justify a cheap knock-off.

    And that's to say nothing of the swindle being attempted through the renamings Reactors have given to the MVC bits of the React ecosystem. The very obvious MVC-with-global-variables patterns were marketed in an aloof, functional-programming-ripoff attempt to have something new to say when in fact they were just the old thing with more transpiler overhead.

    It's mank, and serious engineers are universally confused or pissed off by these cheap marketing theatrics.

  2. Given the hulking payload of Apollo, you'd be forgiven for wondering why it does not simply eat the other eight.

  3. Because it's generally unproductive (not to mention, kind of a dick move) to simply tell someone that their understanding of state management is entirely backward, the best I usually muster in conversation is heightening the contradictions by proximity.

    I wish that putting React's claims about state management next to those from other libraries triggered the same sorts of questions in others that it provokes in me, but the hit rate on this technique is dismal.

    Most developers only really seem to upgrade their understanding of what's possible when they look to add realtime collaboration to their systems, which is both understandable and somewhat frustrating, given the amount of effort that goes into pressing Apollo and Redux into service as ill-fitting sync engines.

  4. These systems might be caches, after a fashion, but they aren't databases, as they don't provide any method for querying data. Most also fail to address local persistence. The kludges I've seen to add storage to them further confirms they are not suited to any sort of scalable access role. It will always be a fool's errand to abuse a thin slice of a data layer snapshot as a generic access abstraction when the structure and metadata needed to do better than O(n^2) are absent.

    Major products have limped along in a self-degraded state, punishing heavy users thanks to approaches that wouldn't pass CS101 evaluations of their scalability. Huge apps have been caught trying (and often failing) to boot from JSON serializations of Redux.

    The fact that it ever worked — even on a developer's beefy machine — is testament to the ingenuity and dedication of hardware engineers, rather than any positive attribute of contemporary software pedagogy or frontend culture.

  5. A single-host instance of RabbitMQ would be just as expressive and generally perform better.

  6. Derogatory.

    For those not versed in both languages state of play, C++ is actually more capable regarding data access and updates over time in a stuctured way than JavaScript; it's just buried in C++11 Memory Model arcana.

    JavaScript, meanwhile, provides neither syntax nor standard library facilities for thinking about the time dimension of execution, despite continuing to slice aspects like program and class initialisation even more finely to accomodate reasonable defaults and their predictable overrides.

  7. The most common objection to the complexity of vector-clock-based update systems is that it isn't necessary in many cases to be able to distinguish now from then or when.

    This is (sometimes) true!

    Many UI components have internal state data that isn't complex enough to need accounting for mutations over time. Those components also don't want to integrate deeply into a single data management pattern, and prefer to handle changes through an out-of-time state machine, or a state chart without history. That's often fine.

    What's less fine is for us to pretend that once we escape the boundaries of these smaller components that we can muddle through with systems that attempt to extend the temporal void to conflicts that arise in application-wide contexts.

    These are fundamentally distinct concerns, and the React-ish belief that we can think of everything as an infinite regression of components without cross-cutting architecture is falsified by the practices of the React community itself.

    Said differently, Redux is a cry for help, and we have solutions that enable us to think more appropriately about how to quench the pain it introduces. But to get there, we have to step out of the void and into the stream of time.

  8. We can't in good conscience call these libraries "state management" tools, but I don't have a better name off the top of my head.

    Possible alternatives:

    • Event blenders
    • State strainers
    • Model muddlers
    • Mutation mixers
    • Modification marshes
    • Component complexifiers
    • Metamorphosis mystifiers
    • Confusion caches
    • Transform tarpits
    • Heap hodgepodges
    • Update imbroglios
    • Status SNAFUs

    If you have a better suggestion, drop a note on Mastodon or bsky.

  9. Honourable mentions for systems that try to deal with sync in a somewhat less structured way:

    • PouchDB: a classic of the genre, the library is continuing to get updates that remove cruft. Unfortunately, the conflict resolution strategy it adopts is too naive to recommend for new production work.
    • RxDB: unfortunately, RxDB leaves conflict handling as an exercise to the reader. Thankfully it does have a CRDT integration story, which is how I would recommend holding it.
    • Replicache: implements full state sync and good conflict control. It's also OSS'd, but somewhat unsupported by Rocicorp now. A good answer for folks who find Y.js too cumbersome but want full offline-first (or even local-first) development with an authoritative store in the cloud.

    Lastly, I would be remiss to omit that Google's Docs API is, in effect, an externalised entrypoint to the full live-editing OT system that powers Google Docs and Docs Offline. Unfortunately neither the server nor the client implementations of Google's modified-OT system are OSS or self-hostable, making it impossible to recommend.

Previously