Category Archives: Uncategorized

Unicode, Bit by Bit

Not long ago I was digging through the string API of a programming language and fell down a rabbit hole into Unicode. I ran little experiments, took a pile of notes, and came away understanding far more than I set out to. This article is my attempt to capture those learnings — the details of how the world’s computers share text — in case they help someone else.

I wrote it for non-programmers too. If you’ve ever wondered how the letters you’re reading right now become something a machine can store and send, that’s what Unicode answers.

I go deep, but I build up from very basic principles: what Unicode is, what counts as a single character, and how text finally turns into bytes.

A roadmap of four steps: Unicode, grapheme clusters, bits and bytes, then encoding text as bytes

Unicode: A Map of Characters

The world has thousands of languages, each with its own characters — letters, accents, marks, and symbols. To work with text from any of them, a computer needs one universal way to catalog every character there is.

Think of a single, giant map with a numbered slot for every character, one map that everyone shares. It has room to grow: new characters take new slots as needs arise — emoji, for one, didn’t exist when the map began, and they simply claimed fresh slots.

Before such a map, every system had its own. ASCII numbered 128 characters, enough for English and little else; Latin-1 covered Western European accents, Shift-JIS covered Japanese, and many more each numbered their own languages. The same number meant different characters from one map to the next, so a file was unreadable unless you already knew which map produced it.

Unicode — officially “The Unicode Standard” — is that shared map. The name is a fair summary: *uni* for universal, one map for every language, and *code* because each character gets its own code, a number. That number is a code point; whenever you see “code point,” just think “the number for that character.” A code point is written with a U+ prefix and its value in hexadecimal — a compact notation we’ll get to shortly — so “A” is U+0041. Each code point names one entry in the map: a letter, a digit, a mark, or a symbol.

A partial Unicode table mapping characters to their code points and decimal values

Unicode covers every writing system in use: Latin, Cyrillic, the CJK scripts for Chinese, Japanese, and Korean, along with mathematical symbols, punctuation, and emoji. One map holds all of them, replacing the old arrangement of a separate map per language.

Code points are organized into blocks, each reserved for a particular script or purpose — one for Greek, large blocks for the CJK characters, a block for emoji, and many more. The very first blocks are what let Unicode take over so smoothly.

By the time Unicode arrived, a huge amount of text and software already used ASCII. How could Unicode replace it without breaking all of that? The clever answer: it kept ASCII’s numbers. The first block is exactly ASCII — the letter A is U+0041, decimal 65, the same number ASCII gave it decades earlier — and the block right after it continues with Latin-1. So old ASCII and Latin-1 text still reads correctly as Unicode.

A table of major Unicode ranges and the scripts or symbols each one covers

What Counts as a Character?

Up to now, one code point has meant one character. That’s usually true, but not always.

Sometimes a handful of code points belong together, combining into a single thing a reader sees as one character. You’d probably just call it a character; Unicode gives it a fancier name, the grapheme cluster. The name is literal — a group of code points *cluster* together to form what looks like one character.

The classic example is é. It can be a single code point (U+00E9), or two — a plain e (U+0065) followed by a combining accent (U+0301) that lands on top of it. On screen they’re indistinguishable, and either way it counts as one grapheme cluster. The grid below shows this, along with two more examples that build on the same idea.

A grid of characters and their code points: é as one code point, é as e plus a combining accent, the US flag as two regional-indicator letters, and a family emoji as four people fused by joiners — each row one grapheme cluster

Emoji lean on this hard. The US flag is a pair of regional-indicator letters, and a family like 👨‍👩‍👧‍👦 fuses a man, woman, girl, and boy with an invisible joiner (U+200D) between each — one character on screen, several code points underneath. So what a person calls one character might be a single code point or a whole cluster of them, and the grapheme cluster is the unit that matches what they actually see.

Either way, it’s all code points underneath, and a code point is just a number. A computer doesn’t store numbers the way we write them, so to see how text is actually stored, we first need to look at how computers handle numbers at all.


A Primer on Bits and Bytes

Now we can turn to how these codes are actually stored on a computer — where the term UTF-8 comes in, which you may have run across before. Getting there takes a little background on how bits and bytes work first. If that’s already familiar, skip ahead to the “Encoding Text as Bytes” section below. But as we know, there are 10 kinds of people: those who understand binary and those who don’t. If you don’t get that joke, read on.

A computer stores everything as bits — 0s and 1s — and a group of bits is just a number written in binary. To see how that works, it helps to start from the counting we already know.

We write numbers in base 10: each column holds a digit from 0 to 9, and every column to the left is worth ten times more — ones (10⁰), tens (10¹), hundreds (10²), and so on.

The number 65 written in base 10

Computers use the same idea in base 2, called binary: each column holds only 0 or 1, and every column to the left is worth twice as much — 2⁰, 2¹, 2², and so on. So everything a computer stores is a number, written as a row of bits.

The number 65 written in binary, base 2

The same idea extends to hexadecimal, base 16. Each column holds a digit from 0 to 15, written 0–9 and then A–F (A is 10, F is 15), and every column to the left is worth sixteen times more.

The number 65 written in hexadecimal, base 16

Hex and binary go hand in hand. One hex digit stands for exactly four bits, so a byte — eight bits — is always two hex digits. That makes hex a compact shorthand for binary: instead of writing out 00011010, you can write 0x1A, where the 0x prefix just marks a hex number.

A byte shown as eight bits and as the two hex digits it equals

Both show up in this article. We’ll lean on hex because it’s shorter to write, and switch to binary only when the individual bits matter. Converting between them is mechanical — plenty of calculators do it, and with a little practice you can do it by hand.

Now that we have the language computers think in — binary — we can look at the unit they group it into: the byte. A byte is a group of 8 bits, and it’s the unit computers use for storage — you store a byte, read a byte, address a byte, rather than handling bits one at a time.

A byte is eight bits grouped together

A file on disk or data sent over a network is just a long sequence of bytes. That shared unit is how both the computer and we measure and talk about data — kilobytes, megabytes, and the rest all build from the byte.

Storage as a sequence of bytes, one after another

A byte only goes so far. Eight bits can hold 256 different values — 0 through 255 — so any number larger than 255 has to spread across more than one byte. Take 700: in binary it’s 1010111100, ten bits — more than one byte holds. It goes into two bytes that together make 700.

💡 When a number spans several bytes like this, those bytes can be laid out in either order — most-significant byte first or last, a choice called endianness — so systems exchanging data have to agree on which they use.

700 split across two bytes — a high byte (0x02) and a low byte (0xBC) — recombined as 2 × 256 + 188 = 700

Splitting a large number across bytes, with an agreed endianness, is the standard, all-purpose way to store any number. Text could ride on it too — each character is a code point, which is a number. But text is common enough to get its own standard, one tuned to store it compactly. The next section builds that standard up from scratch.


Encoding Text as Bytes

Now that we know computers work in numbers and how they store them, we can get to the real goal: storing and moving text — saving it to a file, sending it over a network, holding it in memory. Each of those needs the text as bytes, and a code point is a number, so encoding text means turning each code point into bytes. Unicode defines a few encodings for this, and by far the most common is UTF-8. We’ll build it up slowly — starting from a naive first attempt — so its rules make sense instead of appearing from nowhere.

A first attempt: one fixed size

Take the letter A. It’s code point U+0041, the number 65 — small enough to fit in a single byte: 01000001.

The letter A beside the number 65 in a single byte the hardware can store

Most code points are far bigger than 255, though, so they won’t fit in one byte. An encoding is the rule that turns a code point into bytes, and back. There’s more than one way to write that rule, and the choice matters: disk space costs money and sending data over a network takes bandwidth, so how compactly an encoding packs code points into bytes carries a real cost. Some are efficient, some wasteful. Let’s think it through, starting with the simplest approach.

The simplest encoding is to reserve more than enough space for any code point — the same amount for every one. Code points run up to U+10FFFF, which needs 21 bits, so round up to 4 bytes (32 bits) each. Write each code point as its 4-byte binary number and lay the results end to end. That byte sequence is what you write to disk, hold in memory, or send over the wire — in big-endian or little-endian order, as before, so both sides agree.

Three characters stored as fixed 4-byte slots, with the leading zero bytes of small code points marked as wasted padding

The upside is simplicity. Every character takes the same four bytes, with no exceptions or complications. That makes the bytes easy to reason about: to count the characters in a chunk of bytes, just divide by four — 40 bytes is 10 characters. And you can jump straight to any character, since you know exactly which bytes it occupies: the first three characters are the first twelve bytes, no scanning required.

The downside is wasted space. The letter a is one of the most common characters in English, yet it gets the same 4 bytes as a rarely used emoji. Nearly all English text is ASCII, which needs only 1 byte, so a fixed 4-byte width inflates it by 4×. And it adds up: 4× the bytes means 4× the storage and 4× the data on the wire — slower transfers and more latency on every page, file, and request.

The naive approach is still worth walking through, because it shows how an encoding could work. The space problem has an obvious fix: spend fewer bytes on the common, small code points, and reach for more only when a code point needs them.

A more efficient encoding

Let’s design a variable-width encoding ourselves, one decision at a time.

Start with the first step: use only as many bytes as a code point needs. Small code points get one byte; larger ones get two, three, or four. The range U+0000 through U+007F covers the original ASCII characters, and every one of those fits in a single byte. So plain English text stays exactly the size it was in ASCII.

Using only as many bytes as each code point needs, with ASCII in a single byte

That immediately raises a problem. Imagine reading the bytes one by one, trying to recover the text: how do you tell when a byte starts a new character, versus when it’s part of a character that takes several bytes? With a fixed four bytes each, you never had to ask — a new character began every fourth byte. Now that widths vary, a byte on its own gives no clue.

The same seven bytes grouped two different ways — one giving A, Ω, 🐶, another pairing the bytes differently to give three unknown characters — showing the boundaries are ambiguous

To work out a fix, let’s follow a single character: the Greek letter Ω (omega).

The data bits of Ω — code point 937 in binary

The simplest way to mark the boundaries is to put a byte in front that says how many bytes follow for a code point. It’s like a little marker that tells you what’s coming. That first byte is a count (red below); the bytes after it hold the code point’s data (blue). A value that needs four bytes of data takes a length byte plus those four data bytes — five bytes in all.

Put the length in a byte up front

We can optimize this a little by not wasting a whole byte on the count. Instead, we put the count in the bits on the leading side of the first byte, which leaves room for data bits after it: a leading 0 means a one-byte character, 110 means two bytes, 1110 means three, and 11110 means four. The patterns are chosen cleverly to stay unambiguous — the leading bits alone tell you the length, and no count can be mistaken for another.

Pack the length into the first byte

The one-byte form has a nice property worth pausing on. Its layout — a leading 0 followed by seven data bits — is exactly how ASCII already stores a character. So every ASCII character encodes to the identical single byte in UTF-8, which means any existing ASCII file is already valid UTF-8, unchanged.

Now the bytes that follow. We mark each one the same way — a little indicator at the front — this time with 10, which says “I’m not a count, I’m continuing the character.” Any byte starting with 10 is mid-character, and any other byte begins a new one, so you can always tell where a character starts.

Mark each continuation byte with 10

Here’s the payoff. Once the count sits in the first byte’s leading bits, the rest of that byte is free — so we let it carry data too, wasting nothing. Putting it together: the first byte’s leading bits give the length and its remaining bits hold data, and each continuation byte holds six more. Here are Ω’s ten bits packed into two bytes, sitting in the low slots with a single zero padding the top. The last color is purple: the 10 that opens the continuation byte.

Ω packed into two bytes, with data bits, the byte-count tag, and the continuation marker each in their own color

Those two rules are the whole of UTF-8: a length tag in the leading bits of the first byte, plus continuation bytes prefixed with 10.

The downsides are the price of variable width. The easy math is gone: because characters vary in size, you can’t count them by dividing, and you can’t jump to the tenth character without walking the bytes from the start to find where it begins. Decoding also takes more work than reading fixed-size slots.


From Character to Bytes

Those are real costs, but UTF-8 earns them. It has become the dominant text encoding: the default on the web, and the native encoding in most modern programming languages.

Step back and the whole path comes into view. Unicode gives a character a code point, a single agreed-on number. That number is written in binary and grouped into bytes. And an encoding — UTF-8 — turns it into the exact bytes saved to a file, held in memory, or sent over a network.

Once you know what’s underneath, you start noticing it. The U+1F600 behind an emoji is a code point. A file larger than its character count is holding multi-byte characters. And when “café” shows up as “café”, you know something messed up the encoding.

That’s the reward for going bit by bit. The letters you are reading right now became code points, then bits, then UTF-8 bytes on the way to your screen — and now you know each step of that trip.

Fun with Swift Numbers

Over the years I’ve spent a lot of time poking at Swift’s numeric types — writing little experiments, hitting unexpected results, and going down rabbit holes. Here are ten things that surprised me.


1. Print Lies (A Little)

Before diving in, there’s something important to know: print doesn’t show the full precision of the stored value. Swift uses an algorithm that finds the shortest decimal string that uniquely identifies the stored bits — which means it often looks cleaner than the actual value. If you’re exploring how numbers are stored, this can be misleading:

let x: Double = 0.1
print(x)  // 0.1  ← looks exact

print(String(format: "%.30f", x))  // 0.100000000000000005551115123126

That’s the actual value a Double stores for 0.1—not exactly 0.1, but the nearest representable value in binary floating-point. Throughout this article, we’ll use String(format: "%.Nf") when we need to see what’s actually in memory.


2. Division by Zero Depends on the Type

Integer division by zero is a fatal runtime crash — and you can’t catch it with do/catch. Floating point doesn’t crash. Instead it produces special values:

print(1 / 0)   // Fatal error: Division by zero

print(1 / 0.0)    //  inf
print(0.0 / 0.0)  //  nan

The asymmetry is intentional — integers have no way to represent infinity, so Swift panics. Floating point types (Float, Double) have dedicated bit patterns for these cases.


3. Negative Zero Is a Thing

Floating point has two zeros: 0.0 and -0.0. They compare as equal, but they’re not the same.

You can’t create negative zero with an integer literal.

let negFloatZero: Float = -0
print(negFloatZero) // 0.0, not -0.0

The -0 is integer arithmetic where -0 = 0, so Float gets 0, not -0.0.

So how do you get one? Through an operation:

let negFloatZeroTwo: Float = -3 * 0
print(negFloatZeroTwo) // -0.0

// true — equal, but not the same
print(0.0 == negFloatZeroTwo)

So why does -0.0 exist?

A negative number can get so small that Double can no longer store it — it rounds to zero. Without -0.0, that zero would look positive and 1 / result would give +inf instead of -inf. The sign is preserved via -0.0:

let tinyNegative = -Double.leastNonzeroMagnitude / 2
print(tinyNegative)       // -0.0  ← too small to store, rounds to negative zero
print(1 / tinyNegative)   // -inf  ← sign was preserved, correct result

-0.0 is just a flag that says “this zero came from the negative side.” The sign of the infinity you get from dividing follows the same rules as multiplication — same signs give positive, opposite signs give negative:

print(1 / 0.0)    //  inf  (positive ÷ positive zero)
print(-1 / -0.0)  //  inf  (negative ÷ negative zero — negatives cancel)
print(1 / -0.0)   // -inf  (positive ÷ negative zero)
print(-1 / 0.0)   // -inf  (negative ÷ positive zero)

4. The Classic Floating Point Gotcha

print(0.1 + 0.2 == 0.3)  // false

Every float has a hidden tail of digits. 0.1 is really stored as 0.100000000000000006... — the nearest value the hardware can represent. Add two of these approximations together and you don’t land exactly on a third. In a way, floating point feels less precise the more you look at it.

The fix is Decimal, which stores numbers in base-10 the way humans write them, so 0.1 is actually 0.1 — not a binary approximation of it:

let result: Decimal = 0.1 + 0.2
print(result == 0.3)  // true — Decimal arithmetic is exact in base-10

Use Decimal anywhere exact decimal math matters: money, measurements, user-facing values.


5. Float’s Number Line Has Gaps

You might assume every decimal value is representable in Float — that after 1.0000004 comes 1.0000005, and so on. It doesn’t work that way. Float only has 6–7 significant decimal digits of precision. Beyond that, Float simply can’t distinguish between nearby values — some get skipped entirely.

nextUp returns the very next representable value above a number with nothing in between. Watch what happens:

var f: Float = 1.0
print(f.nextUp)             // 1.0000001
print(f.nextUp.nextUp)      // 1.0000002
print(f.nextUp.nextUp.nextUp) // 1.0000004  ← skipped 3

The Float nearest to 1.0000003 displays as 1.0000004 — both decimal values round to the same stored bit pattern. Like a hotel that skips floor labels, the floor exists, it just has an unexpected number on the door.


6. Converting to Float and Back Is a One-Way Trip

Not all decimal numbers can be represented exactly in binary floating point — and this includes simple-looking values like 15.2. When you write 15.2, both Float and Double store the nearest binary value they can manage. They each have a different nearest value, because they have different precision. Neither one is actually 15.2.

You can see this by printing with enough decimal places to bypass Swift’s default shortest-representation output:

let originalDouble: Double = 15.2
print(String(format: "%.25f", originalDouble))    // 15.1999999999999992894573...  ← what Double actually stores

let convertedToFloat = Float(originalDouble)
print(String(format: "%.25f", convertedToFloat))  // 15.1999998092651367187500...  ← what Float actually stores

let backToDouble = Double(convertedToFloat)
print(String(format: "%.25f", backToDouble))      // 15.1999998092651367187500...  ← precision is gone

print(originalDouble == backToDouble)             // false

The ugly digits in backToDouble aren’t new damage — they were always there in Float, just hidden. Double has enough precision to expose them.


7. Casting Between Numeric Types Can Crash Your App

Converting a Double to Int, or putting a negative number into a UInt, hits a fatal error with no way to catch it. The safe alternative is exactly: — a failable initializer that returns nil instead of crashing. What exactly: is really asking is: does this value require no rounding in the target type? If Float has to approximate at all, it returns nil:

// 1.1 fails — Float and Double round it differently, so the bits don't match:
let d1: Double = 1.1
print(String(format: "%.30f", d1))   // 1.100000000000000088817841970013
print(Float(exactly: d1))            // nil

// 1.5 succeeds — it's a power-of-2 fraction (1 + 2⁻¹), exactly representable in both types:
let d2: Double = 1.5
print(String(format: "%.30f", d2))   // 1.500000000000000000000000000000
print(Float(exactly: d2))            // Optional(1.5)

// 1234.5 also succeeds — 1234 is an integer (exact in Float) and 0.5 is exact, so the sum is too:
let d3: Double = 1234.5
print(String(format: "%.30f", d3))   // 1234.500000000000000000000000000000
print(Float(exactly: d3))            // Optional(1234.5)

// Going Float → Double always succeeds — widening never loses information:
let fWidened: Float = 1.333333
print(Double(exactly: fWidened)!)    // 1.3333330154418945 ← more digits revealed, nothing lost

The surprise is values like 1234.5 passing while 1.1 fails — it’s not about the size of the number, it’s purely whether the value lands exactly on a binary fraction.


8. Float Starts Skipping Integers Above 16,777,216

Float can only represent integers without gaps up to 16,777,216. Beyond that, consecutive integers start sharing the same value — adding 1 does nothing:

let limit: Float = 16_777_216.0
print(limit.nextUp)  // 1.6777218e+07 — skipped 16777217

print(Float(16_777_217) == Float(16_777_216))  // true — they're the same Float

If you’re storing large integers in a Float, they silently lose uniqueness.


9. Float Overflow Becomes Infinity, Not a Crash

Unlike integers, floats don’t crash on overflow — they overflow to infinity:

let huge = Double.greatestFiniteMagnitude    // ~1.8e+308 — largest positive finite Double
print(Float(huge))  // inf

let mostNegative = -Double.greatestFiniteMagnitude  // ~-1.8e+308 — largest negative finite Double
print(Float(mostNegative))  // -inf

10. Hex Floats Use p, Not e

Just like decimal floats use e (1.5e2 = 150.0), hex floats use p, meaning “times 2 to the power of”. Honestly, this is probably one of those things you’ll never remember because you’ll never use it — but it’s good to recognize when you see it:

print(0xFp2)    // 15 × 2² = 60.0
print(0xFp-2)   // 15 × 2⁻² = 3.75
print(0x1.1p1)  // (1 + 1/16) × 2¹ = 2.125

A hex float without an exponent is a compile error — the p is required. You probably won’t write these by hand, but they show up in generated code and low-level bit manipulation.

Refactor Wide, Not Deep: Getting More Out of AI

AI keeps getting better. Every month there are new models, new capabilities, and developers can do more, faster. But if you’ve been using AI where quality matters, you’ve probably noticed something: every time you let it go a little too far, it runs off the rails. Architecture degrades, weird patterns emerge, things get harder to change, and eventually you don’t want to touch the code because you’ve lost context on what it even did.

There’s a paradox here: AI lets you move faster, but the faster you move, the slower you move on the back side repairing what it broke — which encourages you to move slower in the first place.

This is not new to software engineers. I learned this lesson the hard way at one of my first hackathons as a junior developer. I spent sleepless nights for almost a week building a feature the company really wanted. I demoed it to everyone from the founders on down — they were excited and impressed. Only to be incredibly let down when I told them it was nowhere near shipping. I spent my weekends over the next couple months trying to get it into shape, only to realize I needed to rewrite the whole thing. I’d rushed it, never reviewed it, nothing was done right. It looked great in the demo and had zero stability — threading issues everywhere, a total nightmare. Hackathon demos are deceiving. What looks like a polished, vetted feature is often smoke and mirrors. AI output can be the same way, and a good mentor will give you the same advice for both: break it down into small pieces that can be verified before moving on to the next.

Whether you’re building software by hand or using amazing tools like AI, you learn the balance. How much can I go before I need to slow down to gain trust in the code? That threshold has been steadily rising — from autocomplete, to functions, to classes, to clusters of classes — but it still exists. You can only let it go so far.

So the goal becomes minimizing the number of unique problems you’re asking AI to solve at once. There are two ways to approach this, and the difference matters more than you’d think.

Vertical vs. Horizontal Refactors

Imagine a contest: whoever can refactor and merge the most code in a week wins a prize — maybe a Claude subscription so you can refactor even more code. How do you win? There are two very different approaches.

Vertical Refactors

A vertical refactor takes one feature and refactors it top to bottom — UI, service layer, data layer. You’re only touching one feature, so the scope feels contained.

But the AI is tackling a hundred different types of problems in that one feature. If you haven’t given guidance for each of those, it’s going to guess. It’ll reference legacy patterns for some, pull in something from its training data that doesn’t apply to your domain for others. By the time it’s done, you have 10,000 lines of new code and maybe 3,000 of them are wrong.

Now you need to reverse-engineer every decision. Did it get this one right? What about this one? And unlike reviewing a coworker’s PR — where you have shared context, shared training, and trust built from working together — you have zero baseline trust with the AI. It used whatever it found in open source project training and has no knowledge of the hard lessons your team learned: the crash from a year ago, the edge case that only shows up on certain devices, the business rule that exists only in code.

You end up spending an intense amount of time studying that code with zero trust, recreating the entire thing in your head. When you find issues, you’ll likely need to make many corrections — and you can use AI for those too, but that’s just more code you then need to re-review. Your boss keeps asking “Is it ready” your response “I don’t know yet” because you don’t. That’s the opposite of efficient.

Horizontal Refactors

A horizontal refactor takes one type of change and applies it across the codebase. You’re swapping out a framework, migrating an API, adopting a new pattern — the same blueprint, repeated many times.

Up front, you spend time studying how you want the refactor to work. You codify it in a skill or instruction with clear examples. Get that right once. Then ask the AI to repeat it across many files.

What comes out is far more predictable. You generated just as much code, maybe just as much value. It doesn’t look as flashy because you didn’t refactor an entire feature. But the review is dramatically faster because you’re checking the same pattern over and over, not reverse-engineering a hundred unique decisions.

Think of these horizontals as layers in your application. You go through them piece by piece, and each one makes the codebase more consistent, more modern, and more ready for the next layer.

Why Horizontal Wins

The math is straightforward. In a vertical refactor, you spend a small amount of time prompting and a large amount of time reviewing decisions you don’t trust. In a horizontal refactor, you spend a larger amount of time up front defining the pattern, and a much smaller amount of time reviewing because the output is predictable.

Back to our contest — I’d bet my Claude subscription that horizontal refactors would win the day.

As you build up horizontal skills for different types of changes, you’re also building toward the ability to eventually compose them into full vertical refactors — but with real patterns backing each layer, not bespoke AI decisions.

Claude Chain

This is the approach behind Claude Chain, an open source tool I’ve been building. (It uses Claude but has no affiliation with Anthropic — I’m waiting for the cease and desist, at which point I’ll change the name.)

You define a specification and a list of tasks in markdown, and it works through them one by one, staging a pull request for each. It’s a natural fit for horizontal refactors: you define the specification for how to refactor that one thing, create one task per source code file or cluster of files in your codebase, and let it rip. As long as you have the capacity to review those PRs, you can move through them fast.

I’ve tried the same approach with vertical, feature-level work. It’s a waste of time — too many unique decisions, too much variance. But for horizontal refactors where the pattern is well-defined, it works extremely well.

A note on AI and trust: while writing this post, AI helpfully generated a link to someone else’s open source project instead of my own Claude Chain repo. I didn’t catch it until seconds before posting. Case in point for review.

AI for Developers: Pitfalls and Patterns For Production Code

AI is a genuine force multiplier for developers, as I’ve written about before. It can help you communicate, manage your codebase, and ship things faster.

But there’s not really any training on how to use it well, and it’s easy to pick up habits that are counterproductive. Here are some practices and pitfalls I’ve encountered, mostly from doing them myself.

AI Development

1. Your Content Should Still Be Your Own

Let AI edit your thoughts, refine your phrasing, help you research, help you brainstorm. But what you produce should still be your content.

Our expertise as developers is in the choices: design choices, technology choices, API decisions. AI is an input. It gives you options. The expectation is that you iterate with it, consider alternatives, and evaluate tradeoffs. AI may point out something you didn’t think of or present other options, but in the end, you chose that solution over the alternatives, and that’s what matters.

Practically, that means you own the code line by line. If AI inserts something you don’t understand, go look it up. Validate why it did that, whether it’s correct. Either you learn something new, or you find out the AI was wrong. Either way, the expectation is you chose the solution and are ready to defend it.

2. Give AI Context About Your Codebase

AI knows nothing about your code standards unless you tell it. Without documented conventions, styles, or architectural guidance, it’s going to look at whatever patterns already exist and repeat them, whether you like those patterns or not.

Create Agent skills. Write markdown files that describe how you want things done. Without that, every session starts from scratch: AI guesses, you correct, repeat. This is also why I find it less interesting when someone points a model at a cold codebase and demos a feature. That’s neat, but a codebase without documented skills and conventions isn’t really ready for AI yet. The interesting thing to me is what happens when the AI has real context to work with.

Without architectural direction, AI solves the immediate problem by extending whatever’s already there. A 2,000-line class becomes 2,500. It’s not thinking about structure, just shoehorning in the feature. This is how weird patterns start emerging that nobody asked for. Without skills pointing AI in the right direction, it’ll grow a messy architecture faster than a human ever could.

3. Build Feedback Loops for AI

AI should be able to verify whether it got the right result. Unit tests are great for this. But it can also mean a CLI interface that lets AI access your app’s internals, running the same algorithms from the command line. This is especially useful for UI developers who don’t have that by default.

The more ways AI can check its own work, the less you have to catch manually.

Communication

4. Never Answer Someone’s Question With AI

If someone asks you a question, never regurgitate an answer from AI that clearly isn’t your own thinking. That’s a starting point for an answer, maybe, but it shouldn’t be the answer. We’re still humans and we expect to talk to humans. If you don’t have time to answer their question, just send them the prompt. It’s the new “let me Google that for you.” 🙂

5. Leverage AI for Your Writing

If you have a 10-paragraph document that could be 3 concise paragraphs, and you haven’t used AI for that, you’re missing out. If you avoid writing because you hate it, you’re missing out even more. AI can take your complex thoughts and make them coherent with minimal effort.

Use it on Slack posts before sending them to a large channel. Use it to polish technical docs. My first drafts are never good. They always need to be tightened up and made more concise, and that’s where AI shines. Think of it as a free editor that’s always available. Just make sure to ask it to remove those em dashes 🙂

6. Clean Up Your AI-Generated Pull Request Summaries

A pull request summary is used by other developers to understand what your change actually does. If you’re not careful, AI will just spit out what looks like a commit log, and it’s obvious. When that happens, reviewers can’t tell what the true intent is. Start with some background on what the thing does and why, then get into the details. It’s fine to use AI for this, just make sure it tells the real story.

7. Ship Something Real

In the early days of AI, everyone was posting demos. “I built this awesome weather app in 20 minutes.” “I stood up an entire AWS backend in an afternoon.” And for a while, that was genuinely exciting. We were all figuring out what was possible. But people see through that now. We all know you can generate something that looks impressive in a demo but is non-production slop under the hood. The novelty has worn off.

The bar has shifted. What’s impressive now isn’t how fast you built it. It’s whether it actually ships. Whether someone’s using it. Whether you’re using it. Even if the audience is just yourself, putting something into production is a fundamentally different thing than spinning up a demo. I have dozens of unshipped projects myself, and I’ve had to catch myself more than once. Weeks spent on something that never saw the light of day. Your time isn’t free just because AI moves fast.

That’s not to say stop sharing what you’re building. Keep doing that. But lean into sharing what you shipped rather than what you scaffolded.

Rescue Your Dead Vibe-Coded App

Vibe coding lets you rapidly create fragile software. You prompt, it generates code, you see lightning-fast progress. In a few hours, you have something that kind of works. You’re posting on LinkedIn about building a “complex app in 3 hours.”

But once you reach a certain level of complexity, reality hits. Edge cases break things, adding features breaks other parts, and debugging becomes a nightmare. Your motivation crashes and most projects die here, abandoned with half-finished dreams.

I’ve found that what separates shipped projects from abandoned ones is knowing how to rescue the project. This article shares what I learned from rescuing my own dead vibe-coded projects by getting back to the software principles I skipped. Many of these are practices we’ve known for decades, and they work just as well for AI-generated code.

Remedies: How to Rescue Your Project and Ship

1. Cut Scope

This is psychologically hard to admit, but this is often the first place you should start: your original scope was probably too large.

Vibe coding makes it incredibly easy to add features. You ask the AI for one thing, it builds it, you get excited and ask for another, then another. It feels good in the moment. But when you try to rescue all of that code into something shippable, it feels insurmountable. So much code, so many features, none of it quite working.

Boil it down to the minimum thing you want to ship that has value. Save everything else for a later release by putting it behind feature flags, disabling the code, or deleting it altogether if you think you’ll never ship it. Come to this realization quickly and focus on the important parts. Applying the latter steps in this article will be much easier with reduced scope.

A small shipped app is infinitely better than a massive dead one.

2. Establish Standards and Architecture

You’ve already built the app, and the AI probably produced a mess. One feature uses classes, another uses functions, files are in random folders. We’ll get to fixing that later. But first, let’s document the ideal architecture you want for this app. Think of this as defining your target state: how you wish the code was structured, not how it currently is.

What to document:

  • Architecture: Choose a well-known pattern (Domain-Driven Design, MVC, unidirectional architectures, etc.) that the AI will be familiar with. Define your folder structure, module boundaries, data flow, and general naming conventions.
  • Coding standards: Document patterns to follow, code smells to avoid, and how to handle common scenarios (errors, validation, logging, testing). These will likely evolve as you refactor the app. You may not know what you need to document until you start reviewing AI’s output with discipline.

I find this is actually one of the fun parts of using AI. Documenting how you want apps to be written is a great intellectual exercise that challenges you to think through questions you may have never considered before.

You can use AI to generate these docs initially, but review and understand them. I put mine in docs/architecture/ and docs/standards/ and reference them while working with AI. Continually iterate on these as you build.

An important insight: these documents are generic guidance for the AI, not specific to your individual project. They define how you want the AI to write code in general (your preferred patterns, architectures, and standards). This means you can copy these docs between projects and reuse them. I maintain a set of these for different project types like Mac apps, Python apps, etc. that I drop into new projects as starting templates.

When you have architecture and standards, you maintain understanding of your codebase. You know where things go, how they work, and why they’re structured the way they are. This is what keeps you motivated when things get hard.

3. Refactor with Your Standards and Architecture

Now that you have documented standards and architecture, it’s time to actually use them. This step is about refactoring your existing codebase to match the patterns and structure you’ve defined. This will get the app into a shape that you understand and that is hopefully more robust and easier to maintain going forward. This is prime candidate work for using AI.

Start with architecture, as that’s the most important piece. Reorganize your folder structure, establish clear module boundaries, and ensure data flows the way you’ve documented. Once the architectural foundation is solid, you can work on updating the code to follow your standards over time.

This is a piece you’ll be particularly proud of when you’re done, transforming chaos into something intentional and well-structured.

4. Build Feedback Loops

One of the reasons your project may not be shippable is that the AI was building without a way to validate how it was doing. The AI needs to know when its changes break things, and you need to know you’re not introducing regressions. Feedback during software development is not a new concept but matters even more with AI-generated code.

How to provide feedback:

  • Validation Against Standards – After creating a small feature or fixing a bug, have the AI validate its work against the standards and architecture docs you created earlier. This ensures it followed your patterns and conventions. AI tools often have hooks that can automate this validation step.
  • Testing – Use unit tests for individual functions, integration tests for how components work together, and end-to-end tests for complete user workflows. Tests catch regressions and verify the system actually works as intended.
  • Logging and error handling – AI can read log output as it’s running things to see what’s actually happening. Log key operations, state changes, and decision points. AI often generates happy-path code, so ensure every operation that can fail has explicit error handling with clear messages.

5. Review All AI-Generated Code

You’re probably not going to review all the existing code. That ship has sailed. But mark a line in the sand: all new code created during the rescue will be reviewed, every commit and every change. This is part of your strategy for making things better.

Why? Because in nearly every AI-generated change, you’ll find something you want different. Without review, you’ll miss these issues and accumulate more technical debt. Just as importantly, reviewing keeps you connected to your architecture and what’s actually in your codebase.

Conclusion: Ship It

You don’t need to abandon these projects. By cutting scope, establishing standards, refactoring to match them, building feedback loops, and reviewing code, you can rescue that dead vibe-coded app and actually ship it. And on your next project, you can apply these more disciplined approaches from the start and build something maintainable.

Balancing Deep Work with Everything Else

What Is Deep Work?

Deep work is when you spend long, uninterrupted stretches of time focusing on hard problems that require your full attention. Writing code, planning a complex architecture, strategic planning. Not emails, not Slack, not the quick tasks you cross off your list for a momentary sense of accomplishment. Definitely not the paper airplane you built out of tissue paper.

Cal Newport introduced this term, and his book on the subject is worth reading. I’ve read it at least 3 times over the years!

These are the sessions where you grab a cup of coffee and get lost in your work, forget about time. Some call this flow, and for knowledge workers it’s often what makes or breaks a career.

The problem is that your job, coworkers, and life aren’t arranged around deep work. Things beg for your attention from every direction, everything feels like a minor emergency, and in the short term all of it seems more important than that deep work you keep putting off.

These are the most important things I’ve been experimenting with lately that are working well so far.

1: Arrange Deep Work in Sprints

When I’m working on a hard problem, I want to keep going until I’ve captured value. Making that breakthrough often takes more than a few hours, sometimes days to do it well. I find it best to plan for that. I call these sprints, loosely borrowed from the agile model.

I typically do an approximate estimate (“this will take me 2 days”) and sometimes I finish early, sometimes it takes a bit longer. Nobody is going to be able to work 100% on their sprint commitment. Maybe you can spend 2 hours, 4 hours, or 6 hours if you’re lucky. The idea is to commit to some non-negotiable amount you will do each day during that sprint.

At the end of a sprint, I want to feel like I did something important. I moved the work forward and took it to a new level. Now it’s time to stop and look around for what’s next.

2: Include a Random Day Between Sprints

Things will come up every day that you’re not expecting: follow-up emails, research projects, miscellaneous requests. If you can hit your deep work targets and still handle those things that day, great, do it. But that won’t always be the case. When it’s not, I defer them until the sprint is over. Then I take a “random day” to knock them all out.

A random day is when I go through the list of smaller things I deferred. They’re their own mini projects. Sometimes I can do 5 in a day, sometimes 20 depending on the size.

By the time I finish a sprint and have that tremendous sense of accomplishment, those “death by a thousand cuts” tasks suddenly don’t seem so bad. They become a welcome break from the intensity. I take the random day to clear the backlog, then roll into my next deep work sprint.

3: Stop Torturing Yourself with Recurring Tasks

Recurring tasks accumulate and will kill your deep work. Twenty minutes reviewing my calendar, an hour on pull requests, thirty minutes on metrics. Suddenly half your day is gone before you’ve touched your real work. If you want to spend 5 hours coding but have 2 hours of daily obligations, there’s no room left for unexpected emergencies. Something has to give: either you burn out trying to cram in your deep work, or you keep cutting it back until it barely exists.

You don’t need to repeat everything every day. I review pull requests only on Mondays, Wednesdays, and Fridays instead of daily. App metrics I check on Tuesdays and Thursdays.

I’ve surprised myself with how much more efficient I am when I create scarcity of time. And sometimes, problems resolve themselves when I’m not immediately jumping on them.


If you find yourself constantly shifting between small tasks while your most important work never gets done, it might be time to get serious about protecting your deep work. Sustained focus on hard problems over time is what separates good work from great work.

Slack Etiquette: Confessions and Best Practices

We’ve all been there—someone on your team does something on Slack that just grinds your gears. Here are some common habits that might be annoying your coworkers (and you might not even know you’re doing them). Full disclosure: I’ve made many of these mistakes myself.

The Basics

Post, Then Ghost – Don’t ask a question and immediately go offline. You started the conversation—stick around a few minutes to continue the discussion.

Don’t Change the Subject – Someone posts something important, and you immediately pivot to something only semi-related? Not cool.

Complete Your Profile – An incomplete profile slows everything down. If people don’t know who you are or what team you’re on, they don’t know how to respond.

Timing Matters

Respect Do Not Disturb – If someone has set DND status, don’t push through it unless it’s truly urgent.

Time Your Messages – Slack has a scheduling feature—use it. Don’t send direct messages during someone’s offline hours.

Thread Management

Respond with Threads – If someone asks a question in a channel, reply in the thread. Your response likely doesn’t deserve its own top-level post.

Keep Long Posts in Threads – Post a short message in the channel, then reply to yourself with the details.

Remove Wrong People – Figured out who you actually need to talk to? Start a new thread. Don’t keep dragging people into conversations they don’t belong in.

Delete Courtesy Replies – If someone deletes a post message, do them a favor and delete your reply too.

Message Quality

Don’t Just Say “Hi” – Avoid sending a “hey” or “hi” message and waiting for a response. Slack is asynchronous. Say what you need to say upfront instead of expecting a back-and-forth dialogue.

Slow Down on Edits – Making three corrections in the first minute after posting? Take a breath before hitting enter. (I’m most guilty of this).

Use the Snooze Feature – Drowning in messages? Reply to what you can, snooze what needs follow-up.

Be a Human Being

Don’t Bury Posts – Someone just shared a celebration or recognition? Don’t immediately post something else and push their content up where no one will see it.

Recognize Good Work – When something good happens or someone does something well, acknowledge it. Throw an emoji on there. People will notice their supporters and repay.

No Negative Emojis – Don’t thumbs-down someone’s ideas unless they’re asking for a poll. That’s an offline conversation.

Arguments Belong Elsewhere – If you’re getting into it with someone on Slack, stop immediately and talk directly. Slack arguments never work out in anyone’s favor.

Align Your Slack Tone with Your In-Person Tone – Some people are so nice in person but come across as completely different people on Slack. Text doesn’t convey tone—be mindful of how you’re coming across.


Some of these are easier than others (I’m definitely guilty of a few myself). But being aware of these habits can make Slack a much better experience for everyone on your team.

CLI-Driven Development: Building AI-Friendly iOS and Mac Apps

I’ve been using Claude Code for several months now on many personal projects, and I’ve worked out some practices that work really well for me as an iOS and Mac developer. This CLI-driven development approach has fundamentally changed how I build applications with AI assistance.

The Problem with GUI-Based Development

One of the most powerful aspects of AI coding assistants is their ability to receive feedback and iterate on solutions. Give an AI a goal, and it can refine and improve until it gets there. However, if you’re an iOS or Mac developer, you’ve likely hit a wall: GUI interfaces are opaque to AI systems.

You could set up tooling to capture simulator screenshots, but this approach is slow and error-prone. By the time the AI gets a screenshot, analyzes it, and suggests changes, you’ve lost the rapid iteration cycle that makes AI assistants so valuable in the first place.

The other option is writing comprehensive unit tests. If you’re not doing test-driven development with AI yet, CLI-driven development is a nice stepping stone toward that goal. It has the added flexibility of interacting with real data—somewhat like an end-to-end test. Tests are still important, but this is another tool in your toolbelt for those not ready to go full TDD.

The goal is to give the AI the full context of your running application so it can fully interact with it.

Note: See the caveats section below regarding respecting user privacy and security when giving AI access to application data.


The Solution: CLI-Driven Development

CLI-driven development means architecting your application so that every use case accessible via the UI is also accessible via a command-line interface. The UI and CLI becomes access points to these use cases, rather than containing the business logic itself.

This isn’t a new idea. We’ve been told for years not to put business logic in view controllers or SwiftUI views. When working with AI, this separation becomes critical.

Benefits

  1. Better Architecture: Enforces separation between UI and business logic
  2. Faster Debugging: AI can identify and fix issues more quickly
  3. Faster Feature Development: AI has a way to give itself feedback
  4. Improved Testability: These principles make your app more unit-testable too
  5. Easier Data Migrations: AI can access and transform your real data

Architecture: Three Targets

I’m vastly oversimplifying the architectural requirements for a real application here. Folks will have their choice of patterns, frameworks, and approaches. I’m focusing on the minimal Swift package setup that will allow for this flow to work.

Think of your application as having three distinct targets within a Swift package:

  1. UI Target: Contains your SwiftUI views, view controllers, and UI interactions
  2. CLI Target: Handles command-line input/output
  3. Core Target: Contains all business logic, services, data interactions, and workflows

Both the UI and CLI targets are thin layers that simply pass data to the Core. When a user taps a button, the UI sends data to a service. When you run a CLI command, it does the same thing.

Shared Services in Core

This approach requires discipline you probably want anyway: every new feature or interaction needs a single method that can be called with all necessary parameters, with all the important logic happening outside your views. These shared services live in your Core target.

Here’s a brief example of a service to add a TODO:

Core Target – TodoService.swift:

public class TodoService {
    public func addTodo(listName: String, item: String) throws {
        // All business logic lives here
        let list = try getOrCreateList(name: listName)
        let todo = Todo(title: item, completed: false)
        list.todos.append(todo)
        try persistenceManager.save(list)
    }
}

CLI Target – main.swift:

// Parse command: ./MyAppCLI add-todo --list "Grocery" --item "Buy milk"
let service = TodoService()
try service.addTodo(listName: listName, item: item)
print("Added: \(item)")

Mac App Target – AddTodoView.swift:

struct AddTodoView: View {
    @State private var itemName = ""
    let listName: String
    let service = TodoService()

    var body: some View {
        TextField("Item", text: $itemName)
        Button("Add") {
            try? service.addTodo(listName: listName, item: itemName)
        }
    }
}

Both the CLI and Mac app call the exact same TodoService.addTodo() method. The business logic is shared, and both interfaces benefit from any improvements or fixes.


What Can You Do With This?

Debugging with AI

Let’s say you see an issue when adding a TODO to your Grocery list in the Mac app. Here’s how the conversation might go:

> I see a bug when adding a TODO to my Grocery list. Check it out.

Let me test this using the CLI:

$ ./MyAppCLI add-todo --list "Grocery" --item "Buy milk"

$ ./MyAppCLI list-todos --list "Grocery"
Grocery List:
  - Buy milk
  - Buy milk

I see the issue - the TODO is being added twice. Let me check the add-todo implementation...

[AI fixes the duplicate insertion bug]

$ ./MyAppCLI list-todos --list "Grocery"
Grocery List:
  - Buy milk

Fixed! The issue was in the data persistence layer where we were saving twice

The AI can use the CLI to reproduce the issue, see what’s happening, and iterate on fixes until it’s working correctly.

Building Features Faster

When building a feature, you can give the AI requirements and have it verify its work. Here’s an example:

> Add a feature to mark TODOs as complete. When I mark a TODO as complete,
> it should show with a checkmark when I list them. Verify it with the CLI
> and make sure it works before you're done.

I'll implement the complete-todo feature. Let me build it and test it:

[AI implements the feature in Core target]
[AI adds CLI command for marking complete]
[AI updates list display to show completion status]

Now let me test it:

$ ./MyAppCLI add-todo --list "Grocery" --item "Buy milk"
Added: Buy milk

$ ./MyAppCLI add-todo --list "Grocery" --item "Buy eggs"
Added: Buy eggs

$ ./MyAppCLI list-todos --list "Grocery"
Grocery List:
  - [ ] Buy milk
  - [ ] Buy eggs

$ ./MyAppCLI complete-todo --list "Grocery" --item "Buy milk"
Marked complete: Buy milk

$ ./MyAppCLI list-todos --list "Grocery"
Grocery List:
  - [] Buy milk
  - [ ] Buy eggs

Perfect! The feature is working. TODOs now track completion status and
display with checkmarks when completed.

The AI builds the feature and verifies it works before reporting back. In theory, you’ll see the same results in the UI application.

Data Migration and Transformation

During early iterations, you frequently need to migrate data when properties change. With a CLI, you can ask the AI to transform all your proof-of-concept data because it has access to all the APIs necessary to read and write data.

> Migrate my TODO data from the old JSON format to the new format.
> Make a backup first, then verify the results match exactly.

$ ./MyAppCLI export-data --output todos_backup.json
Exported 127 TODOs to todos_backup.json

[AI implements migration from old format to new nested format with IDs]

$ ./MyAppCLI migrate-data --from todos_backup.json --to-new-format
Migrating 127 TODOs...
Migration complete.

$ ./MyAppCLI verify-migration --old todos_backup.json --new current
 All 127 TODOs migrated successfully
 Data integrity verified

Migration complete. Backup saved at todos_backup.json.

Important Caveats

This is Not a Replacement for Unit Tests

Unit testing leads you toward similar practices of keeping services separate from UI. Following CLI-driven development actually makes your app more testable. I suggest using this approach for both unit testing and CLI-driven development. Don’t focus on CLI-driven development alone.

Security and Privacy Considerations

You must respect your customers’ privacy and security. Be mindful when giving AI access to data and understand your privacy requirements. The CLI should be treated as a development tool with the same security considerations as direct database access.

The Dangers of Mixing Locks with Core Data

Codebases accumulate patterns over time that don’t match up with current best practices. These patterns might have made sense when they were written, or they might just reflect how our understanding has evolved. Before you can address  these patterns, you need to spot them and understand why they’re risky.

One particularly dangerous combination in iOS codebases is mixing locks (@synchronized, NSLock, etc.) with Core Data’s performAndWait. These patterns were used to maintain synchronous operation patterns, but together they create hidden cross-thread dependencies that lead to deadlocks, freezing your app.

This shows exactly how these deadlocks occur, so you can recognize and avoid them in your own code.

A Simple Shared Class

Let’s start with a basic class that manages some shared state. This shows a common pattern from before Swift concurrency – using dispatch queues to manage background work. This class might be accessed from multiple threads:

  • Main thread: reads the operation status description
  • Background thread: Starts a background operation
class DataProcessor {
    var currentOperationIdentifier: String = ""
    var currentOperationStatus: String = ""

    // Called from main thread
    func getDescription() -> String {
        return "Operation \(currentOperationIdentifier) has status: \(currentOperationStatus)"
    }
    
   // Called from background thread
  func startBackgroundOperation() {
      currentOperationIdentifier = "DataSync"
      currentOperationStatus = "Processing"
      // Do processing
  }
}

The Problem – Race Conditions

When dealing with multiple threads, execution can interleave unpredictably. One thread executes some code, then another thread slips in and executes its code, then back to the first thread – you have no way of knowing the order.

Here’s what can happen:

Background ThreadMain Thread
currentOperationIdentifier = “DataSync”
(about to update status…)
getDescription()
reads identifier → “DataSync” ✓
reads status → “Idle” ❌ (old value!)
currentOperationStatus = “Processing”
❌ too late – main thread already read old value

The main thread ends up with the new identifier but the old status – a mismatch that leads to inconsistent data.

There are better solutions to this problem – like bundling related state in one immutable structure, or using actors in modern Swift. But in legacy codebases, synchronous locks were a common strategy to protect shared state.

Adding Locks for Thread Safety

The lock creates “critical sections” – ensuring we either write to both properties OR read from both without other threads interfering.

class DataProcessor {
    private let lock = NSLock()
    var currentOperationIdentifier: String = ""
    var currentOperationStatus: String = ""

    func getDescription() -> String {
        lock.lock()
        defer { lock.unlock() }
        
        return "Operation \(currentOperationIdentifier) has status: \(currentOperationStatus)"
    }

    func startBackgroundOperation() {
        lock.lock()
        defer { lock.unlock() }

        currentOperationIdentifier = "DataSync"
        currentOperationStatus = "Processing"
    }
}

So far, this works fine. The locks protect our shared state, and both threads can safely access the properties.

The Deadlock – When Locks Meet Core Data

Now let’s assume we want to store this data to Core Data. This is where things get interesting.

When sharing Core Data across threads, you can run into race conditions just like we had earlier. So you need to use the right APIs to protect the critical sections too.

Your go-to is performBlock – it asynchronously performs the work safely. However, there are cases in legacy code where the caller needs to do something synchronously using performAndWait. When you call performAndWait on a main queue context, it blocks the calling thread until the block executes on the main thread. Think of waiting on the main queue as our “lock”.

Let’s assume some developer in the past (who definitely isn’t you) decided to use performAndWait here:

func startBackgroundOperation(with context: NSManagedObjectContext) {
    lock.lock()
    defer { lock.unlock() }
    
    // Assume the main thread tries to call
    // getDescription() at this point. 
    // It is blocked as we are holding the lock

    currentOperationIdentifier = "DataSync"
    currentOperationStatus = "Processing"

    // 💀 DEADLOCK HAPPENS HERE
    context.performAndWait {
        saveDataToStore(context: context)
    }
}

Why Does This Deadlock?

There’s a problem:

  • performAndWait needs the MAIN THREAD to execute this block
  • The MAIN THREAD is blocked waiting for our lock (in getDescription)
  • We’re holding that lock and won’t release until performAndWait completes

CIRCULAR WAIT = DEADLOCK

Timeline of the Deadlock

Background ThreadMain Thread
lock.lock() ✅
Updates propertiesCalls getDescription()
Still holding lock…             lock.lock() ❌ WAITING…
Waiting on performAndWait() (needs main thread)Can’t process – stuck waiting on lock!
  • Main thread: stuck in lock.lock() waiting for background thread
  • Background thread: stuck in performAndWait waiting for main thread

 How to Fix This Deadlock

The best solution is to eliminate performAndWait entirely and use the asynchronous perform instead. This breaks the circular dependency because the background thread no longer waits for the main thread:

func startBackgroundOperation(with context: NSManagedObjectContext) {
    lock.lock()
    defer { lock.unlock() }

    currentOperationIdentifier = "DataSync"
    currentOperationStatus = "Processing"

    // ✅ No deadlock
    // doesn't block waiting for main thread
    context.perform {
        self.saveDataToStore(context: context)
    }
}

If you absolutely cannot eliminate performAndWait, you’ll need to carefully analyze all lock dependencies, but this is error-prone and hard to maintain. The real fix is embracing asynchronous patterns.

What We Learned

In this article, we’ve seen how mixing locks with Core Data’s performAndWait creates a classic deadlock scenario:

  1. Race conditions can occur when multiple threads access shared mutable state
  2. Locks were traditionally used to protect this shared state with critical sections
  3. performAndWait works like a lock but requiring the main thread to execute
  4. When a background thread holds a lock and calls performAndWait, while the main thread is waiting for that same lock, we get a circular dependency – neither thread can proceed

Coming Up



Future articles will explore other ways you can hit or avoid these deadlocks:

  • Child contexts with read operations – Why using a child context doesn’t save you from deadlocks during fetch operations
  • Child contexts with write operations – How save operations on child contexts create the same circular dependencies
  • Private Contexts – Why private contexts with direct store connections are less likely to lock up

A Simple Checklist for Debugging Regressions

I’ve been thinking about a process I’ve used for resolving regressions that may be useful to share. I’ve never explicitly written the steps down before, but figured it was worth capturing—both for myself and others.

When a regression shows up, there are three questions that I’ve found you have to answer. Skipping any of them usually leads to confusion, wasted time, or a fix that doesn’t actually solve the real problem. But if you take the time to work through them, you can usually land on the right answer with confidence.

1. Can you reproduce the problem?

A lot of engineers want to jump straight into the code. That’s the fun part, right? Digging through logic, inspecting diffs, reasoning your way to a fix. But if you can’t reliably reproduce the issue, studying the code is usually a waste of time. You don’t even know where to look yet.

Reproducing the problem is the first real step. It’s not glamorous, and it can feel a little silly—especially when you’re trying the same steps over and over with tiny variations. But this is one of the most valuable things you can do when a bug shows up.

As engineers, we have a special vantage point. We know how the code works, and we often have a gut instinct about what kinds of conditions might trigger strange behavior. That gives us a real edge in uncovering subtle issues—so don’t think you’re above tapping on an iPad for hours or running the same test over and over. It’s our duty to chase it down.

Once you have a reliable repro, everything gets easier. You can try fixes, stress other paths, and most importantly, build real confidence that your solution works.

Some useful tricks:

  • Adjust timing, inputs, or state to help provoke the bug
  • Script setup steps or test data to save time
  • Loop the behavior or stress threads to make edge cases more likely

2. What changed?

This step is often skipped. People jump into debugging without first understanding what changed. But the fastest way to track down a regression is to compare working code to broken code and see what’s different.

This question can feel sensitive. It gets close to specific contributions that may have introduced instability. I’ve seen plenty of cases where the discussion gets deflected into vague root causes or long-term issues—anything but the specific change. That’s understandable. We’ve all been there. But avoiding the question doesn’t help. It puts the fix at risk and slows everyone down.

Some go-to techniques:

  • Review pull requests and diffs
  • Trace from crash logs or error messages to recently changed code
  • Use git bisect to find the breaking commit
  • Try reverting the suspected change and see if the issue disappears

Once you find the change, test your theory. If undoing it makes the problem go away, you’re on the right track.

3. Why does it happen?

Knowing what changed isn’t enough. You need to understand why that change caused the issue. Otherwise, you’re just fixing symptoms, and might miss deeper problems.

This is where the real problem solving happens:

  • Read documentation for the APIs or system behavior involved
  • Think through the interaction between components or timing
  • Build a mental model and prove it with experiments or targeted tests

You don’t want to ship a fix that works by accident. You want one that works because you actually understand the problem. That’s what prevents repeat issues and edge cases slipping through.

Wrapping up

These three questions — Can you reproduce it? What changed? Why does it happen? — have helped me find and fix bugs more reliably than anything else.

It’s easy to skip them under pressure. It’s tempting to merge the first thing that seems to work. But without answering all three, you’re flying blind. You might get lucky. Or you might end up wasting hours chasing your tail or shipping the wrong fix.