4 posts on API Design

On making simple things easy and complex things possible

16 min read Report broken page

The holy grail of good API design is making complex things possible and simple things easy. But is it enough?

Alan Kay [source]

One of my favorite product design principles is Alan Kay’s “Simple things should be simple, complex things should be possible”. [1]

I had been evangelizing the principle before I knew about the Alan Kay quote, and have tried to follow it it to nearly everything I have designed in the last 20 years, from end-user apps to programming languages.

However, there is a lot more to it than meets the eye, and applying it well can be nontrivial. As usual, the devil is in the details. Keep reading and maybe you won’t have to spend 20 years discovering them for yourself!

Long-Tail UIs

Since Alan Kay was a computer scientist, his quote is typically framed as a PL or API design principle. But that is downplaying its importance — programming languages and APIs are only a small subset of the interfaces it applies to.

Making simple things easy and complex things possible is great guidance for nearly every interface where use cases are sufficiently varied so that some or most are simple but there is a long tail of complex use cases, each of them being niche individually, but all together representing a significant portion of total user needs.

For lack of a better term, let’s call these Long-Tail UIs. Nearly all interfaces that help humans create artifacts fall in this category. This includes creative tools such as development environments, design tools, programming languages, APIs, etc, but is not limited to them.

Take Google Calendar for example. While it could be argued that it is a tool that helps humans create artifacts (calendar events), it is not really a creative tool. And yet, it’s definitely a Long-Tail UI: its use cases are sufficiently varied that most are simple, but there is a large tail of complex use cases (e.g. recurring events, guests, multiple calendars, timezones, etc.).

Indeed, Kay’s maxim has clearly been used in its design. The simple case has been so optimized that you can literally add a one hour calendar event with a single click (using a placeholder title). You can also drag to make adjustments to the times and duration without typing [2]. But almost every possible edge case is also catered to — with additional user effort.

Google Calendar is squarely a long-tail UI.

While creative tools are the poster child of long-tail UIs, there are long-tail UI components in many interfaces generally designed around transactional processes such as e-commerce or meal delivery (e.g. result filtering & sorting, product personalization interfaces, etc.).

Airbnb’s filtering UI here is definitely making an effort to make simple things easy (personalized shortcuts!) and complex things possible.

While some interfaces benefit more than others from making simple things easier or more complex things possible, it’s actually quite difficult to find interfaces that do not benefit at all. These exceptions tend to mainly revolve around cases where one of the two is either not desirable (e.g. for security, safety, or performance reasons) or out of scope by design.

Incremental value should require incremental user effort

I used to think that making simple things easy and complex things possible is the begin-all and end-all of good interface design. But over time, I realized it’s only a good first step (and still surprising how many interfaces fail it!).

Picture a plane with two axes: the horizontal axis being the complexity of the desired task (Use case complexity), and the Y axis the cognitive and/or physical effort users need to put into using the interface to accomplish their task.

Alan Kay's maxim visualized.

Alan Kay’s maxim can be visualized as follows:

  • Simple things being easy means there should be a point on the lower left (low use case complexity → low user effort).
  • Complex things being possible means there should be a point somewhere on the far end. The lower down the better (lower user effort), but higher up is acceptable.

But even if we get these two points — what about all the points in between? There are a ton of different ways to connect them, and they are most definitely not equal in terms of overall user experience!

Avoid usability cliffs

Simple use cases are often the spherical cows in space of product design. They work great for prototypes to convince stakeholders, but the real world is messy. It is very easy once someone starts really using a product to start encountering use cases that are still conceptually simple — just with a few warts here and there. If users are thrown into the deep end when that happens, overall user experience is very poor, because the use case still feels like it should be simple.

Let’s take the HTML <video> element as an example. Simple things are certainly easy: all we need to get a nice sleek toolbar that works well on every device is a single attribute: controls. We just slap it on our <video> element and bam, we’re done with a single line of HTML:

<video src="videos/cat.mp4" controls></video>

➡️ A cat video player with a sleek toolbar

Now let’s suppose use case complexity increases juuuust a little bit. Maybe I want to add buttons to jump 10 seconds back or forwards. Or a language picker for subtitles. Or key moment indicators, like YouTube. Or just to hide the volume control on a video that has no audio track. None of these are particularly niche, but the default controls are all-or-nothing: the only way to change them is to reimplement the whole toolbar from scratch, which takes hundreds of lines of HTML, CSS, and JavaScript to do well.

A usability cliff is when a small increase in use case complexity requires a large increase in user effort.

Simple things are easy and complex things are possible. But once use case complexity crosses a certain (low) threshold, user effort abruptly shoots up.

This is called a usability cliff, and is common when products make simple things easy and complex things possible by providing two distinct interfaces: a very high level one that caters to the most common use case, and a very low-level one that is an escape hatch: it lets users do whatever but they have to reimplement everything from scratch.

For delightful user experiences, making simple things easy and complex things possible is not enough — the transition between the two should also be smooth. The user effort required to achieve incremental value should be proportional to the value gained. There should be no point where incremental value requires orders of magnitude more user effort.

You can visualize this like that:

A delightful user experience has a smooth power-to-effort curve without cliffs. The slower the rare of increase, the better.

Apply the principle recursively

Long-tail UIs rarely have a uniform long tail. Typically, some complex use cases are more simple than others. Therefore, one good way to avoid cliffs is to apply the principle recursively. Once you’ve made simple use cases easy, consider the remaining use cases. Which ones are simplest among them? Then optimize them too.

This was a big reason why PrismJS, a syntax highlighting library I wrote in 2012, became so popular, reaching over 2 billion downloads on npm and being used on some pretty huge websites [3].

Simple things were easy: highlighting code on a website that used good HTML took nothing more than including a CSS file and a script tag. Because its only hook was regular HTML, and there was no Prism-specific “handshake” in the markup, it was able to work across a large variety of toolchains, even tooling where authors had little control over the HTML produced (e.g. Markdown).

Complex things were possible: it included a simple, yet extensive system of hooks that allowed plugin authors to modify its internal implementation to do whatever by basically inserting arbitrary code at certain points and modifying state.

But beyond these two extremes, the principle was applied recursively: Common complex things were also easier than uncommon complex things. For example, while adding a new language definition required more knowledge than simply using the library, a lot of effort went into reducing both the effort and the knowledge required to do so. Styling required simple CSS, styling simple, readable class names. And as a result, the ecosystem flourished with hundreds of contributed languages and themes.

This is a very common pattern for designing extensible software: a powerful low-level plugin architecture, with easier shortcuts for common extensibility points.

Tweakable over take-it-or-leave-it

Many complex use cases are just simple use cases with a few tweaks. Rather than canned take-it-or-leave-it solutions for simple cases (which as we’ve seen, tend to produce cliffs), a good way to smoothen the curve and require only incremental effort for incremental value is to make these solutions tweakable. Tweaking tends to be much easier than starting from scratch, so you want to limit starting from scratch to the cases where it’s actually desirable.

With tweakable shortcuts, even if the simple solution does not fully cover the use case, the only additional user effort required is that needed to tweak it so that it does. The flow serves a dual purpose: it gives simple use cases a solution, and complex use cases a headstart.

This is the core issue with the <video> example: The way it makes simple things easy is completely inflexible. There are no extensibility points, no way to customize anything. It’s take it or leave it.

While web components are not typically the poster child of good user experiences, there is one aspect of web component APIs that allows them to provide a very smooth power-to-effort curve: slots. Slots are predefined insertion points with defaults. If I’m writing a <my-video> component, I can define its default play button like this:

<button id="play">
	<slot name="play-button-icon">▶️</slot>
</button>

And now, a component consumer can use <my-video src="cat.mp4"> and get a default play button, or slot in their own icon:

<my-video src="cat.mp4">
	<i slot="play-button-icon" class="fa-solid fa-play"></i>
</my-video>

But the best thing about slots is that they can be nested. This means that component authors can defensively wrap parts of the UI in slots, and component consumers can override just the parts they need, at the granularity they need. For example, <my-video> could also wrap the default play button itself in a slot:

<slot name="play-button">
	<button id="play">
		<slot name="play-button-icon">▶️</slot>
	</button>
</slot>

And then, component consumers can still only override the icon, or override the whole button:

<my-video src="cat.mp4">
	<button slot="play-button">
		<i slot="play-button-icon" class="fa-solid fa-play"></i>
	</button>
</my-video>

Empty slots facilitate insertion points. For example, the <my-video> component author could support inserting controls before or after the play button like so:

<slot name="play-button-before"></slot>
<slot name="play-button">
	<button id="play">
		<slot name="play-button-icon">▶️</slot>
	</button>
</slot>
<slot name="play-button-after"></slot>

And then, component consumers can use them to add additional controls:

<my-video src="cat.mp4">
  <button slot="play-button-before" class="skip-backwards"><svg>…</svg></button>
  <button slot="play-button-after" class="skip-forwards"><svg>…</svg></button>
</my-video>

Given enough extension points, users would only need to resort to building custom controls from scratch when they truly have a very complex use case that cannot be implemented as a delta over the default controls. That smoothens out the curve, which may look more like this:

A custom video component that uses slots extensively can smoothen the curve.

Let’s get back to Google Calendar for another example. Suppose we want to create a recurring event. Even within the less simple use case of creating a recurring event, there are simpler use cases (e.g. repeat every week), and more complex ones (e.g. every third week, on the third Sunday of the month, twice a week etc.).

Google Calendar has used tweakable presets to make simple things easy and complex things possible at the micro-interaction level. Simple things are easy: you just pick a preset. But these are not just presets, they are also tweakable shortcuts. They also serve as entrypoints into the more “advanced” interface that can be used to set up almost any rule — with enough user effort.

Tweakable presets smoothen the curve exactly because they contain the additional user effort to only the delta between the user’s use case, and the simpler use case the interface is optimized for. By doing that, they also become a teaching tool for the more advanced interface, that is much more effective than help text, which is typically skimmed or ignored.

Google Calendar recurring event presets Google Calendar recurring event customization dialog
Google Calendar making simple things easy and complex things possible at the micro-interaction level.

A hierarchy of abstractions

So far, both tweakable abstractions we have seen revolved around extensibility and customization — making the solution to simple use cases more flexible so it can support medium complexity use cases through customization.

The version of this on steroids is defining super low-level primitives as building blocks that make complex things possible, and then composing them into various high-level abstractions that make simple things easy.

My favorite end-user facing product that does this is Coda. If you haven’t heard of Coda, imagine it as a cross between a spreadsheet, a database, and a document editor.

Coda implements its own formula language, which is a way for end users to express complex logic through formulas. Think spreadsheet formulas, but a lot better. For many things, the formula language is its lowest level primitive.

Then, to make simple things easy, Coda provides a UI for common cases, but here’s the twist: The UI is generating formulas behind the scenes that users can then tweak! Whenever users need to go a little beyond what the UI provides, they can switch to the formula editor and tweak the generated formula, which is infinitely easier than writing it from scratch.

Let’s take the filtering interface as an example, which I have written about before. At first, the filtering UI is pretty high level, designed around common use cases:

Another nice touch: “And” is not just communicating how multiple filters are combined, but is also a control that lets users edit the logic.

For the vast majority of use cases, the high-level UI is perfectly sufficient. If you don’t need additional flexibility, you may not even notice the little f button on the top right. But for those that need additional power it can be a lifesaver. That little f indicates that behind the scenes, the UI is actually generating a formula for filtering. Clicking it opens a formula editor, where you can edit the formula directly:

I suspect that even for the use cases that require that escape hatch, a small tweak to the generated formula is all that is necessary. The user may have not been able to write the formula from scratch, but tweaking is easier. As one data point, the one time I used this, it was just about using parentheses to combine AND and OR differently than the UI allowed.

Smoothening the curve is not just about minimizing user effort for a theoretical user that understands your interface perfectly (efficiency), it’s also about minimizing the effort required to get there (learnability). The fewer primitives there are, the better. Defining high-level abstractions in terms of low-level primitives is also a great way to simplify the user’s mental model and keep cognitive load at bay. It’s an antipattern when users need to build multiple different mental models for accomplishing subtly different things.

When high-level abstractions are defined as predefined configurations of the existing low-level primitives, there is only one mental model users need to build. The high level primitives explain how the low-level primitives work, and allow users to get a headstart for addressing more complex use cases via tweaking rather than recreating. And from a product design perspective, it makes it much easier to achieve smooth power-to-effort curves because you can simply define intermediate abstractions rather than having to design entirely separate solutions ad hoc.

For the Web Platform, this was basically the core point of the Extensible Web Manifesto, which those of you who have been around for a while may remember: It aimed to convince standards editors and browsers to ship low-level primitives that explain how the existing high-level abstractions worked.

Low-level doesn’t mean low implementation effort

Low-level primitives are building blocks that can be composed to solve a wider variety of user needs, whereas high-level abstractions focus on eliminating friction for a small set of user needs. Think of it that way: a freezer meal of garlic butter shrimp is a high-level abstraction, whereas butter, garlic, and raw shrimp are some of the low-level primitives that go into it.

The low-level vs high-level distinction refers to the user experience, not the underlying implementation. Low-level primitives are not necessarily easier to implement, and are often much harder. Since they can be composed in many different ways, there is a much larger surface area that needs to be designed, tested, documented, and supported. It’s much easier to build a mortgage calculator than a spreadsheet application.

As an extreme example, a programming language is one of the most low-level primitives possible: it can build anything with enough effort, and is not optimized for any particular use case. Compare the monumental effort needed to design and implement a programming language to that needed to implement e.g. a weather app, which is a high-level abstraction that is optimized for a specific use case and can be prototyped in a day.

As another extreme example, it could even be argued that an AI agent like ChatGPT is actually a low-level primitive from a UX perspective, despite the tremendous engineering effort that went into it. It is not optimized for any particular use case, but with the right prompt, it can be used to effectively replace many existing applications. The floor and ceiling model also explains what is so revolutionary about AI agents: despite having a very high ceiling, their floor is as low as it gets.

Respect user effort

If incremental value should require incremental user effort, an obvious corollary is that things that produce no value should not require user effort. And yet, it’s surprising how often interfaces require users to do work that confers them absolutely no benefit, such as dealing with complexity that is not relevant to them, or doing work that the UI should be doing for them.

Treat user effort as a scarce resource and keep it close to the minimum necessary to declare intent. The user experience of expending effort without getting any value in return just because an interface requires jumping through certain hoops to use is demoralizing; the UI equivalent of red tape. On the other hand, interfaces where every bit of user effort required is meaningful and produces tangible value are a joy to use. Guess which one is more likely to minimize churn?

@@@ lazy coding prioritizing developer convenience over user experience

Reveal complexity progressively

Complexity should be tucked away until it’s needed. Users should not have to deal with complexity that is not relevant to them. Enterprise software, I’m looking at you.

For example, individual user accounts should not need to set up “workspaces” separately from setting up their account, or designate representatives for different business functions (legal, accounting, etc.). This is complexity that belongs to complex use cases leaking out to simple ones. Any concepts exposed through a UI should add user-facing value. If a concept does not add user-facing value, it should not be exposed to users.

And for APIs, this emphasizes the importance of sensible defaults and shortcuts. Users should be able to use an API without making micro-decisions about things they don’t care about or learning parts of the API that are not relevant to them.

My favorite example of this is the SVG DOM.

It’s fantastic that it has been typed from the get go. It’s great that it provides access to both the animated value and the base value.
It’s not great that all this complexity is thrown at users whether it’s relevant to them or not.
There is no simple way to just get a value and stuff it elsewhere, like there is for the HTML DOM. All the complexity of the more advanced use cases is thrown at users whether it’s relevant to them or not.
Complex things are possible, but simple things are not easy. There is no curve, just a horizontal line.

Screenshot of a console showing someone repeatedly trying to read the actual radius of an SVG circle, until eventually they get it right: circle.r.baseVal.value
It's great that the SVG DOM supports all this. It's not great that users need to deal with it whether it's relevant to them or not.

Support the user’s mental model

Requiring users to perform mental gymnastics to translate their mental model to the interface’s is another form of disrespecting user effort. What does that look like? Take a look at these two common types of faucets:

Simple to use does not necessarily mean simple to implement.

When using a bathroom faucet, the user’s mental model is around water temperature and pressure, not hot and cold water amounts. It’s the underlying implementation that expects amounts of hot and cold water as inputs.

The first faucet is a thin abstraction: it exposes the underlying implementation directly, passing the complexity on to users, who now need to do their own translation of temperature and pressure into amounts of hot and cold water. It prioritizes implementation simplicity at the expense of wasting user effort.

The second design prioritizes user needs and abstracts the underlying implementation to support the user’s mental model. It provides controls to adjust the water temperature and pressure independently, and internally translates them to the amounts of hot and cold water. This interface sacrifices implementation simplicity to minimize user effort.

Implementation details leaking out into the UI is a very common UX antipattern. Sometimes it happens simply because abstraction is hard. It’s much easier to expose what’s already there than to rethink it. Other times, it reflects a prioritization of developer convenience over user experience. We’ll address this in the next section.

Compute what can be computed

Users should not have to expend cognitive effort computing things that the interface could be computing for them. If you can calculate something from user input, don’t require them to enter it manually. This does not only reduce superfluous user effort, it also reduces preventable errors.

As an example, take the removeChild() DOM method, used to remove a child element from its parent. In the abstract, its signature seems reasonable:

parent.removeChild(child);

But this means two variables that need to have a very specific relationship are entered as separate inputs. This introduces an error condition, for the case where these variables don’t have that relationship. In practice, the relationship is enforced by having a single variable, and deriving the other one from it:

element.parentNode?.removeChild(element);

While this eliminates the error condition, it still adds superfluous gruntwork into the user experience. User intent is muddled by noise.

Contrast this with the newer:

element.remove();

Now code reflects the user intent with no superfluous noise. If the element parent is required for the operation of removing the element, it is simply computed from it by the implementation of the remove() method.

Reduce gruntwork and boilerplate

Boilerplate is repetitive syntax that users need to include without thought, because it does not actually communicate intent. It’s just a little song and dance that the user needs to do before they can communicate intent.

Just like computation, this kind of gruntwork is much better suited to machines, rather than humans.

There are three different causes for this:

  1. Not enough effort has been put into making simple things easy, i.e. complexity is not being revealed progressively when needed
  2. It is driven by a need to avoid being “opinionated”
  3. The wrong technology is being used to solve the problem and does not support sufficient automation

For 1, see the previous section on revealing complexity progressively.

Let’s talk a bit about 2, as this is a common point of friction between UX/product folks and engineers.

If it can be generated, generate it.

The previous point is about reducing cognitive user effort by eliminating superfluous computation from the user experience, whereas this is about reducing physical effort by not requiring manual effort that does not advance the interface’s understanding of user intent.

Let’s take an example from Bootstrap:

Do not require users to type boilerplate. They are not your monkeys.

Specifying defaults that nearly never need customization also fall in this category, even if they technically are declaring intent. Intent should be the delta from the average case, otherwise you may as well require users to specify your entire implementation. It’s your interface

The simplicity that matters

For a long time, I used to argue that the principle should be “Common things should be easy, uncommon things should be possible”. Often, the most common use cases are not at all simple!

Which comes first, convenience or capability?

Sometimes the stars align and you come up with a single solution that gives you a smooth, wide curve. But often, the way to achieve that smooth curve is to layer multiple solutions, some optimized for simple use cases and others for complex ones.

Ideally, these are not independent, but build on top of each other. For example, a high-level solution may be essentially a smart preset that configures multiple low-level primitives for a specific use case.

Alan Kay was a brilliant computer scientist, but he was very much a scientist, not a product manager. Therefore, his wise maxim does not deal with prioritization between the two.

Sure, both are important. But which one do you ship first? Which one do you design first? You can rarely do both at the same time. In the real world when you ship matters just as much as what you’re shipping.

Two fundamentally different layering strategies.

This is a controversial topic, and the right answer is generally It Depends™. But when it comes to the Web Platform, after 14 years of designing and reviewing features for it, I have concluded that unless there is a good reason for the opposite, starting by designing low-level primitives tends to be the safer bet.

Convenience for growth, power for retention

Prioritizing low-level primitives will make most product folks gasp. For regular products, it’s rare to enter a market where there is no existing product making things possible. Therefore, often the best strategy to achieve product-market-fit is to pick the right use cases and optimize the hell out of them. Additionally, since low-level primitives are often more work, the economics of shipping them are not always favorable.

The conventional product wisdom is right when the main goal is growth. It’s a common misconception among engineers that to facilitate user acquisition, you need to build something more powerful or higher quality. More often than any of those, users flock to products that reduce the floor (easier to get started) and make things easier overall. We glorify hardcore engineering but most successful software innovations have been usability innovations at their core. Stripe was just a way to make online payments easy. Dropbox could (and was) seen as a high-level abstraction over existing OS primitives, but it was easier. iPhone was easier to use than the smart phones that came before it. Instagram provided an easier way to do photo manipulation that looked good without being an expert (lower floor). Slack was easier than the market leaders of the time — and of course IRC. And the list goes on. Even the Web itself was a usability innovation. All it did at the start was already possible via FTP — but the Web provided a much easier, more streamlined way to accomplish these use cases.

So, power is not what will get you to PMF — unless you are the first to provide power, which is rare. But making complex things possible is key to retention, i.e. preventing churn. Reality is messy, and the less common use cases are bound to come up eventually. If you’ve done your homework and optimized for the right use cases, it will take enough time for that to happen that some customer loyalty will have formed and switching costs will no longer be zero. But it is almost a certainty that it will happen. And if your product has no escape hatches, if there are no workarounds that make the more complex things possible, users leave. So perhaps we rephrase Alan Kay’s maxim with a product twist: users come to a product because it makes simple things easy, and stay because it makes complex things possible.

But the Web Platform is a very unique “product”: when it comes to building websites, it has no competitor. It’s not like browsers ship with a couple alternative web platforms that web developers can use instead. Web Platform technologies only compete among themselves: if CSS or HTML doesn’t do what you need, you can often do it in JS, but these types of solutions tend to come at a cost. And when something is not possible with any Web Platform technology, users are just stuck.

When it comes to building apps, the Web Platform is competing against native platforms. And indeed, when developers switch to native platforms, it’s rarely because the Web Platform made common things hard — it’s usually because it made certain things impossible. There are still native capabilities and optimizations that the Web Platform does not expose and can still only be accessed through native platforms, e.g. the ability to react to have voice commands anywhere in the OS perform actions in the app.

Low-level solutions buy you time

Pick any creative product, or any platform, and browse its user feedback forum. Unless it already has a very high ceiling, you will find it’s littered with requests for capabilities, with very few requests for convenience. This is not because friction doesn’t matter. But being stuck hurts a lot more than being inconvenienced.

Users are rarely vocal about friction. When there is a workaround, however suboptimal, users often push through and forget about it. It only bubbles up as a complaint when the hassle is both significant and frequent. Often they don’t even identify friction as a problem, because they expect things to be hard. Until they see a competitor that makes things easy, and the cycle repeats.

Shipping a low-level solution that can function as a workaround for a host of use cases, even if it’s not a primary solution for any of them buys you time. It gives users a way out. They don’t have to flock elsewhere just to get stuff done. Even if its usability is abysmal, the gap can be briefly bridged with customer support and education — for a bit. It doesn’t suffice, but it reduces urgency, and buys you more time to get the high-level solution right.

And getting it right matters a lot; the stakes are higher when it comes to designing the right high-level solution. A suboptimal low-level primitive usually translates to too much friction (Hello WebRTC! How are you doing today Web Components?), but it usually still serves its core purpose of making complex things possible. But a high-level solution that misses the mark about which use cases to optimize for is practically useless.

Low-level primitives lead to better high-level solutions

Starting low-level often produces better overall designs, both in terms of a smooth power-to-convenience curve and in terms of preventing overfitting. And it does this in two ways, with both feeding into each other.

Shipping a low-level solution first means you can now collect valuable data about how it is used, and make more informed decisions about the high-level solution. Seeing what users actually do with the low-level building blocks tests your hypotheses about what what they need and how common it is.

Out of the various web technologies I’ve designed over the years, Relative Colors are definitely in the top 3 I’m most proud of. They unlocked so many possibilities for color manipulation, most of which I never imagined when I first proposed them.

Back then, we envisioned most of their usage to be fairly simple, mainly around additions, multiplications, and replacing entire components with constants. Things like this:

--color-accent: oklch(70% 0.155 205);
--color-accent-95: oklch(from var(--color-accent) 97% c h);
--color-accent-darker: oklch(from var(--color-accent) calc(l * 0.8) c h);
--color-accent-50a: oklch(from var(--color-accent) l c h / 50%);

In practice, it turned out that real-world usage required much more complex math to derive the kinds of aesthetically pleasing colors that even come close to what a designer would create [1]. It was fortunate that Relative Colors were designed as a more general low-level primitive — an eigensolution if you will — and thus could accommodate use cases far more complex than what they were originally envisioned for.

Rather than going straight for the most high-level solution right after, a common path involves progressively shipping composable shortcuts and abstractions to make common patterns of using the low-level primitives easier. But the way these are used also give you more data, so by the time you get to the high-level solution, you have an unparalleled understanding of user needs.

Use case variability as a factor

A good high-level solution addresses a high enough chunk of user needs to justify its implementation effort, as well as the additional UI complexity of integrating it. It could be framed as an instance of the Pareto principle: 80% of user needs are concentrated on 20% of use cases — the challenge is finding the right 20%.

However, there are instances where user needs are (or appear to be) so variable that it becomes very hard to carve out a group that could reasonably be addressed by the same high-level solution. In such cases, a low-level solution is the only viable approach.

And on the other extreme, there are instances where user needs are so concentrated on very few use cases that a high-level solution can address nearly all of them, giving you the best of both worlds. The next section includes an example of this.

A big challenge here is that often use cases appear less varied at first than they later turn out to be, and by the time you realize your blind spots, it’s too late and you’ve already shipped a high-level solution that is overfit.

Who remembers node.compareDocumentPosition()? It was a lower-level function that returned a bitmask (!) telling you everything you may possibly want to know about the relationship between two nodes in the DOM. However, this is an instance where user needs were very highly concentrated around the same use case: testing whether en element contains another. This API made complex things possible that nobody wanted and simple things were very convoluted:

if (!!(el1.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_CONTAINS)) {
  // el1 contains el2
}

This was later recognized and a much simpler el1.contains(el2) function was added.

When decomposition introduces issues

I said that starting low-level tends to be a safer bet, unless there is a good reason not to. One such reason is when exposing lower-level primitives would involve negative security, privacy, or performance implications that a more tightly coupled high-level solution can avoid.

Power can crowdsource convenience

Not all creative tools have extensible architectures, but for those that do, shipping low-level building blocks lets power users join forces in making common things easy. E.g. if your product supports a plugin architecture, ensuring that this is sufficiently powerful means that users can also make common things easy, by authoring plugins. This benefit is not just restricted to users: it also lets you test out different ideas for high-level solutions through plugins and test the waters, without having to commit to supporting them long term and with a much lower bar than shipping them as part of the core product.

The Web Platform is the poster child for this. Indeed, this was exactly the central point of the Extensible Web Manifesto, which those of you who have been around for a while may remember: ship low-level primitives first, and then web developers can make common things easy through libraries and frameworks.

Unfortunately, as often happens, the nuance was lost in translation and the EWM ended up becoming an excuse to only work on low-level capabilities. Absolutes are easier to deal with, so humans frequently try to skew nuanced guidance towards extremes. Indeed, I would not be surprised if people try to do the same with this essay, and reply “but high-level solutions are important too!”, entirely missing the point that this is about prioritization, not picking sides.


  1. this deserves a whole other post, which is on my to-do list ↩︎


Forget “show, don’t tell”. Engage, don’t show!

4 min read Report broken page

A few days ago, I gave a very well received talk about API design at dotJS titled “API Design is UI Design” [1]. One of the points I made was that good UIs (and thus, good APIs) have a smooth UI complexity to Use case complexity curve. This means that incremental user effort results in incremental value; at no point going just a little bit further requires a disproportionately big chunk of upfront work [2].

Observing my daughter’s second ever piano lesson today made me realize how this principle extends to education and most other kinds of knowledge transfer (writing, presentations, etc.). Her (generally wonderful) teacher spent 40 minutes teaching her notation, longer and shorter notes, practicing drawing clefs, etc. Despite his playful demeanor and her general interest in the subject, she was clearly distracted by the end of it.

It’s easy to dismiss this as a 5 year old’s short attention span, but I could tell what was going on: she did not understand why these were useful, nor how they connect to her end goal, which is to play music. To her, notation was just an assortment of arbitrary symbols and lines, some of which she got to draw. Note lengths were just isolated sounds with no connection to actual music. Once I connected note lengths to songs she has sung with me and suggested they try something more hands on, her focus returned instantly.

I mentioned to her teacher that kids that age struggle to learn theory for that long without practicing it. He agreed, and said that many kids are motivated to get through the theory because they’ve heard their teacher play nice music and want to get there too. The thing is… sure, that’s motivating. But as far as motivations go, it’s pretty weak.

Humans are animals, and animals don’t play the long game, or they would die. We are programmed to optimize for quick, easy dopamine hits. The farther into the future the reward, the more discipline it takes to stay motivated and put effort towards it. This applies to all humans, but even more to kids and ADHD folks [3]. That’s why it’s so hard for teenagers to study so they can improve their career opportunities and why you struggle to eat well and exercise so you can be healthy and fit.

So how does this apply to knowledge transfer? It highlights how essential it is for students to a) understand why what they are learning is useful and b) put it in practice ASAP. You can’t retain information that is not connected to an obvious purpose [4] — your brain will treat it as noise and discard it.

The thing is, the more expert you are on a topic, the harder these are to do when conveying knowledge to others. I get it. I’ve done it too. First, the purpose of concepts feels obvious to you, so it’s easy to forget to articulate it. You overestimate the student’s interest in the minutiae of your field of expertise. Worse yet, so many concepts feel essential that you are convinced nothing is possible without learning them (or even if it is, it’s just not The Right Way™). Looking back on some of my earlier CSS lectures, I’ve definitely been guilty of this.

As educators, it’s very tempting to say “they can’t possibly practice before understanding X, Y, Z, they must learn it properly”. Except …they won’t. At best they will skim over it until it’s time to practice, which is when the actual learning happens. At worst, they will give up. You will get much better retention if you frequently get them to see the value of their incremental imperfect knowledge than by expecting a big upfront attention investment before they can reap the rewards.

There is another reason to avoid long chunks of upfront theory: humans are goal oriented. When we have a goal, we are far more motivated to absorb information that helps us towards that goal. The value of the new information is clear, we are practicing it immediately, and it is already connected to other things we know.

This means that explaining things in context as they become relevant is infinitely better for retention and comprehension than explaining them upfront. When knowledge is a solution to a problem the student is already facing, its purpose is clear, and it has already been filtered by relevance. Furthermore, learning it provides immediate value and instant gratification: it explains what they are experiencing or helps them achieve an immediate goal.

Even if you don’t teach, this still applies to you. I would go as far as to say it applies to every kind of knowledge transfer: teaching, writing documentation, giving talks, even just explaining a tricky concept to your colleague over lunch break. Literally any activity that involves interfacing with other humans benefits from empathy and understanding of human nature and its limitations.

To sum up:

  1. Always explain why something is useful. Yes, even when it’s obvious to you.
  2. Minimize the amount of knowledge you convey before the next opportunity to practice it. For non-interactive forms of knowledge transfer (e.g. a book), this may mean showing an example, whereas for interactive ones it could mean giving the student a small exercise or task. Even in non-interactive forms, you can ask questions — the receiver will still pause and think what they would answer even if you are not there to hear it.
  3. Prefer explaining in context rather than explaining upfront.

“Show, don’t tell”? Nah. More like “Engage, don’t show”.

(In the interest of time, I’m posting this without citations to avoid going down the rabbit hole of trying to find the best source for each claim, especially since I believe they’re pretty uncontroversial in the psychology / cognitive science literature. That said, I’d love to add references if you have good ones!)


  1. The video is now available on YouTube: API Design is UI Design ↩︎

  2. When it does, this is called a usability cliff. ↩︎

  3. I often say that optimizing UX for people with ADHD actually creates delightful experiences even for those with neurotypical attention spans. Just because you could focus your attention on something you don’t find interesting doesn’t mean you enjoy it. Yet another case of accessibility helping everyone! ↩︎

  4. I mean, you can memorize anything if you try hard enough, but by optimizing teaching we can keep rote memorization down to the bare minimum. ↩︎


Mass function overloading: why and how?

4 min read 0 comments Report broken page

One of the things I’ve been doing for the past few months (on and off—more off than on TBH) is rewriting Bliss to use ESM 1. Since Bliss v1 was not using a modular architecture at all, this introduced some interesting challenges.

Continue reading