cognitive-load README

The project's README from github.com/zakirullin/cognitive-load. As one markdown file: README.md.

Cognitive load is what matters

For AI agents | Blog | Chinese | Japanese | Spanish | Korean | Turkish | Brazilian Portuguese | Vietnamese | Nepali

It is a living document, last update: June 2026.

Introduction

There are so many buzzwords and best practices out there, but most of them have failed. They failed because they were imagined, not real. These ideas were based on aesthetics and subjective judgments. We need something more fundamental, something that can't be wrong.

Sometimes we feel confusion going through the code. Confusion costs time and money. Confusion is caused by high cognitive load. It's not some fancy abstract concept, but rather a fundamental human constraint. It's not imagined, it's there and we can feel it.

Since we spend far more time reading and understanding code than writing it, we should constantly ask ourselves whether we are embedding excessive cognitive load into our code.

It is especially important in the AI age, when we need to process a lot of LLM-generated code with our own brains.

Cognitive load

Cognitive load is how much a developer needs to think in order to complete a task.

When reading code, you put things like values of variables, control flow logic and call sequences into your head. The average person can hold roughly four such chunks in working memory. Once the cognitive load reaches this threshold, it becomes much harder to understand things.

Let's say we have been asked to make some fixes to a completely unfamiliar project. We were told that a really smart developer had contributed to it. Lots of cool architectures, fancy libraries and trendy technologies were used. In other words, the author had created a high cognitive load for us.

Cognitive load

We should reduce the cognitive load in our projects as much as possible.

Cognitive load and interruptions

We are going to use "cognitive load" in an informal sense; sometimes it lines up with the specific scientific concept of Cognitive Load, but we don't know enough about where it does and doesn't match.

Types of cognitive load

Intrinsic - caused by the inherent difficulty of a task. It can't be reduced, it's at the very heart of software development.

Extraneous - created by the way the information is presented. Caused by factors not directly relevant to the task, such as smart author's quirks. Can be greatly reduced. We will focus on this type of cognitive load.

Intrinsic vs Extraneous

Let's jump straight to the concrete practical examples of extraneous cognitive load.


We will refer to the level of cognitive load as follows:
🧠: fresh working memory, zero cognitive load
🧠++: two facts in our working memory, cognitive load increased
🤯: cognitive overload, more than 4 facts

Our brain is much more complex and unexplored, but we can go with this simplistic model.

Complex conditionals

if val > someConstant // 🧠+
    && (condition2 || condition3) // 🧠+++, prev cond should be true, one of c2 or c3 has to be true
    && (condition4 && !condition5) { // 🤯, we are messed up by this point
    ...
}

Introduce intermediate variables with meaningful names:

isValid = val > someConstant
isAllowed = condition2 || condition3
isSecure = condition4 && !condition5 
// 🧠, we don't need to remember the conditions, there are descriptive variables
if isValid && isAllowed && isSecure {
    ...
}

Nested ifs

if isValid { // 🧠+, okay nested code applies to valid input only
    if isSecure { // 🧠++, we do stuff for valid and secure input only
        stuff // 🧠+++
    }
} 

Compare it with the early returns:

if !isValid
    return
 
if !isSecure
    return

// 🧠, we don't really care about earlier returns, if we are here then all good

stuff // 🧠+

We can focus on the happy path only, thus freeing our working memory from all sorts of preconditions.

Inheritance nightmare

We are asked to change a few things for our admin users: 🧠

AdminController extends UserController extends GuestController extends BaseController

Ohh, part of the functionality is in BaseController, let's have a look: 🧠+
Basic role mechanics got introduced in GuestController: 🧠++
Things got partially altered in UserController: 🧠+++
Finally we are here, AdminController, let's code stuff! 🧠++++

Oh, wait, there's SuperuserController which extends AdminController. By modifying AdminController we can break things in the inherited class, so let's dive in SuperuserController first: 🤯

Prefer composition over inheritance. We won't go into detail - there's plenty of material out there.

Too many small methods, classes or modules

Method, class and module are interchangeable in this context.

Mantras like "methods should be shorter than 15 lines of code" or "classes should be small" turned out to be somewhat wrong.

Deep module - simple interface, complex functionality
Shallow module - interface is relatively complex compared to the small functionality it provides

Deep module

Having too many shallow modules can make it difficult to understand the project. Not only do we have to keep in mind each module's responsibilities, but also all their interactions. To understand the purpose of a shallow module, we first need to look at the functionality of all the related modules. Jumping between such shallow components is mentally exhausting, linear thinking is more natural to us humans.

Information hiding is paramount, and we don't hide as much complexity in shallow modules.

I have two pet projects, both of them are somewhat 5K lines of code. The first one has 80 shallow classes, whereas the second one has only 7 deep classes. I haven't been maintaining any of these projects for one year and a half.

Once I came back, I realised that it was extremely difficult to untangle all the interactions between those 80 classes in the first project. I would have to rebuild an enormous amount of cognitive load before I could start coding. On the other hand, I was able to grasp the second project quickly, because it had only a few deep classes with a simple interface.

The best components are those that provide powerful functionality yet have a simple interface.

John Ousterhout, A Philosophy of Software Design

The interface of the Unix I/O is very simple. It has only five basic calls:

open(path, flags, permissions)
read(fd, buffer, count)
write(fd, buffer, count)
lseek(fd, offset, referencePosition)
close(fd)

A modern implementation of this interface has hundreds of thousands of lines of code. Lots of complexity is hidden under the hood. Yet it is easy to use due to its simple interface.

This deep module example is taken from the book A Philosophy of Software Design by John Ousterhout. Not only does this book cover the very essence of complexity in software development, but it also has the greatest interpretation of Parnas' influential paper On the Criteria To Be Used in Decomposing Systems into Modules. Both are essential reads. Other related readings: A Philosophy of Software Design vs Clean Code, It's probably time to stop recommending Clean Code, Small Functions considered Harmful.

Important things should be big, examples
Clean vs Dirty
If you allow your important "crux" functions to be larger ("dirty") it is easier to pick them out from the sea of functions, they are obviously important: just look at them, they are big!
This picture is taken from Codin' Dirty article by Carson Gross. You'll find real world examples of deep functions there.

P.S. If you think we are rooting for bloated God objects with too many responsibilities, you got it wrong.

Responsible for one thing

All too often, we end up creating lots of shallow modules, following some vague "a module should be responsible for one, and only one, thing" principle. What is this blurry one thing? Instantiating an object is one thing, right? So MetricsProviderFactoryFactory seems to be just fine. The names and interfaces of such classes tend to be more mentally taxing than their entire implementations, what kind of abstraction is that? Something went wrong.

We make changes to our systems to satisfy our users and stakeholders. We are responsible to them.

A module should be responsible to one, and only one, user or stakeholder.

This is what this Single Responsibility Principle is all about. Simply put, if we introduce a bug in one place, and then two different business people come to complain, we've violated the principle. It has nothing to do with the number of things we do in our module.

But even now, this rule can do more harm than good. This principle can be understood in as many different ways as there are individuals. A better approach would be to look at how much cognitive load it all creates. It's mentally demanding to remember that change in one place can trigger a chain of reactions across different business streams. And that's about it, no fancy terms to learn.

Too many shallow microservices

This shallow-deep module principle is scale-agnostic, and we can apply it to microservices architecture. Too many shallow microservices won't do any good - the industry is heading towards somewhat "macroservices", i.e., services that are not so shallow (=deep). One of the worst and hardest to fix phenomena is so-called distributed monolith, which is often the result of this overly granular shallow separation.

I once consulted a startup where a team of five developers introduced 17(!) microservices. They were 10 months behind schedule and appeared nowhere close to the public release. Every new requirement led to changes in 4+ microservices. It took an enormous amount of time to reproduce and debug an issue in such a distributed system. Both time to market and cognitive load were unacceptably high. 🤯

Is this the right way to approach the uncertainty of a new system? It's enormously difficult to elicit the right logical boundaries in the beginning. The key is to make decisions as late as you can responsibly wait, because that is when you have the most information at hand. By introducing a network layer up front, we make our design decisions hard to revert right from the start. The team's only justification was: "The FAANG companies proved microservices architecture to be effective". Hello, you got to stop dreaming big.

The Tanenbaum-Torvalds debate argued that Linux's monolithic design was flawed and obsolete, and that a microkernel architecture should be used instead. Indeed, the microkernel design seemed to be superior "from a theoretical and aesthetical" point of view. On the practical side of things - three decades on, microkernel-based GNU Hurd is still in development, and monolithic Linux is everywhere. This page is powered by Linux, your smart teapot is powered by Linux. By monolithic Linux.

A well-crafted monolith with truly isolated modules is often much more flexible than a bunch of microservices. It also requires far less cognitive effort to maintain. It's only when the need for separate deployments becomes crucial, such as scaling the development team, that you should consider adding a network layer between the modules, future microservices.

Feature-rich languages

We feel excited when new features got released in our favourite language. We spend some time learning these features, we build code upon them.

If there are lots of features, we may spend half an hour playing with a few lines of code, to use one or another feature. And it's kind of a waste of time. But what's worse, when you come back later, you would have to recreate that thought process!

You not only have to understand this complicated program, you have to understand why a programmer decided this was the way to approach a problem from the features that are available. 🤯

These statements are made by none other than Rob Pike.

Reduce cognitive load by limiting the number of choices.

Language features are OK, as long as they are orthogonal to each other.

Thoughts from an engineer with 20 years of C++ experience ⭐️
I was looking at my RSS reader the other day and noticed that I have somewhat three hundred unread articles under the "C++" tag. I haven't read a single article about the language since last summer, and I feel great!

I've been using C++ for 20 years for now, that's almost two-thirds of my life. Most of my experience lies in dealing with the darkest corners of the language (such as undefined behaviours of all sorts). It's not a reusable experience, and it's kind of creepy to throw it all away now.

Like, can you imagine, the token || has a different meaning in requires ((!P<T> || !Q<T>)) and in requires (!(P<T> || Q<T>)). The first is the constraint disjunction, the second is the good-old logical OR operator, and they behave differently.

You can't allocate space for a trivial type and just memcpy a set of bytes there without extra effort - that won't start the lifetime of an object. This was the case before C++20. It was fixed in C++20, but the cognitive load of the language has only increased.

Cognitive load is constantly growing, even though things got fixed. I should know what was fixed, when it was fixed, and what it was like before. I am a professional after all. Sure, C++ is good at legacy support, which also means that you will face that legacy. For example, last month a colleague of mine asked me about some behaviour in C++03. 🤯

There were 20 ways of initialization. Uniform initialization syntax has been added. Now we have 21 ways of initialization. By the way, does anyone remember the rules for selecting constructors from the initializer list? Something about implicit conversion with the least loss of information, but if the value is known statically, then... 🤯

This increased cognitive load is not caused by a business task at hand. It is not an intrinsic complexity of the domain. It is just there due to historical reasons (extraneous cognitive load).

I had to come up with some rules. Like, if that line of code is not as obvious and I have to remember the standard, I better not write it that way. The standard is somewhat 1500 pages long, by the way.

By no means I am trying to blame C++. I love the language. It's just that I am tired now.

Thanks to 0xd34df00d for writing.

Business logic and HTTP status codes

On the backend we return:
401 for expired JWT token
403 for not enough access
418 for banned users

The engineers on the frontend use backend API to implement login functionality. They would have to temporarily create the following cognitive load in their brains:
401 is for expired JWT token // 🧠+, ok just temporarily remember it
403 is for not enough access // 🧠++
418 is for banned users // 🧠+++

Frontend developers would (hopefully) introduce some kind numeric status -> meaning dictionary on their side, so that subsequent generations of contributors wouldn't have to recreate this mapping in their brains.

Then QA engineers come into play: "Hey, I got 403 status, is that expired token or not enough access?" QA engineers can't jump straight to testing, because first they have to recreate the cognitive load that the engineers on the backend once created.

Why hold this custom mapping in our working memory? It's better to abstract away your business details from the HTTP transfer protocol, and return self-descriptive codes directly in the response body:

{
    "code": "jwt_has_expired"
}

Cognitive load on the frontend side: 🧠 (fresh, no facts are held in mind)
Cognitive load on the QA side: 🧠

The same rule applies to all sorts of numeric statuses (in the database or wherever) - prefer self-describing strings. We are not in the era of 640K computers to optimise for memory.

People spend time arguing between 401 and 403, making decisions based on their own mental models. New developers are coming in, and they need to recreate that thought process. You may have documented the "whys" (ADRs) for your code, helping newcomers to understand the decisions made. But in the end it just doesn't make any sense. We can separate errors into either user-related or server-related, but apart from that, things are kind of blurry.

P.S. It's often mentally taxing to distinguish between "authentication" and "authorization". We can use simpler terms like "login" and "permissions" to reduce the cognitive load.

Abusing DRY principle

Do not repeat yourself - that is one of the first principles you are taught as a software engineer. It is so deeply embedded in ourselves that we can not stand the fact of a few extra lines of code. Although in general a good and fundamental rule, when overused it leads to the cognitive load we can not handle.

Nowadays, everyone builds software based on logically separated components. Often those are distributed among multiple codebases representing separate services. When you strive to eliminate any repetition, you might end up creating tight coupling between unrelated components. As a result, changes in one part may have unintended consequences in other seemingly unrelated areas. It can also hinder the ability to replace or modify individual components without impacting the entire system. 🤯

In fact, the same problem arises even within a single module. You might extract common functionality too early, based on perceived similarities that might not actually exist in the long run. This can result in unnecessary abstractions that are difficult to modify or extend.

Rob Pike once said:

A little copying is better than a little dependency.

We are tempted to not reinvent the wheel so strongly that we are ready to import large, heavy libraries to use a small function that we could easily w

cognitive-load (zakirullin/cognitive-load)

Searching Database…

这是什么?这是一份持续更新的活文档,定义了“认知负荷”,即开发者为理解代码需要付出多少思考,并给出具体、非审美层面的做法,用于减少其中不必要的(外在的)部分。在我们审查大量由 LLM 生成的代码的今天,这一点显得尤为重要。
这是工具、库,还是只是一份阅读指南?它是一篇单独的长篇文章或 README,持续更新,并非软件。它通过代码示例和示意图来说明这一概念,不提供 API,也没有安装步骤。
我该怎么读这份文档?直接在 GitHub 上从“引言”开始往下读,再看具体主题,包括复杂条件判断、嵌套 if、继承、浅层模块、微服务、特性丰富的语言等。已有中文、日语、西班牙语、韩语、土耳其语、巴西葡萄牙语、越南语和尼泊尔语的译本。
这里使用的“认知负荷”是一个精确、经过测量的概念吗?不是。作者明确表示,这一概念是非正式使用的,并承认它不总是与科学意义上的认知负荷概念一致,因此这些建议基于经验,而非经过实证验证。
这份文档是写给谁的?写给开发者和代码审查者,帮助他们用一种客观的方式为设计决策提供理由,而不只是凭个人品味。作者认为,在 AI 时代这一点更加重要,因为很多待审查的代码是由 LLM 生成的。
这里的“深”模块和“浅”模块有什么区别?深模块的接口简单,却隐藏了复杂的功能,例如 Unix I/O 的五个基本系统调用;浅模块的接口则几乎和它提供的那点简单功能一样复杂。浅模块过多,会成倍增加理解它们之间如何交互所需的认知负荷。
为什么本指南反对微服务?作者认为,过早地把系统拆成过多的浅层微服务,会形成“分布式单体”,带来难以接受的认知负荷和上市时间成本。文中举了一家初创公司的例子:它搭建了 17 个微服务,结果比计划晚了 10 个月。作者建议,只有在确实需要独立部署时,才引入网络边界。
它是否认为函数和类应该始终很小?不是。作者质疑“方法和类要小”这一口号,认为重要的“核心”函数可以写得更大一些,允许它不那么“干净”,这样它们才能在众多较小的辅助函数中清楚地凸显出来。
为什么它批评 C++ 这类特性丰富的语言?作者引用了 Rob Pike 的观点,认为额外的语言特性会成倍增加读者日后在脑中重建代码含义时必须做出的选择。文中以作者二十年来处理 C++ 未定义行为的各种怪癖,以及词法单元含义之间的细微差别为例加以说明。
Creator
zakirullin
Size
13.6 MB
Stars
12.5K
Age
3y
Commits · 90 Days