How I used AST-based deobfuscation to split an 11MB JavaScript bundle into 4,728 modules and discovered that AI-generated fixes created new bugs.

Repository: All code, patches, tests, and the AST splitting tool are available at github.com/secemp9/claude-code-ui-freeze-investigation


The Moment My Terminal Froze

Since a couple days now, if not months, I noticed Claude code kept having these issues, yes, the good ol' flickering is still there, but there is a new one: freezing. I noticed it every day at this point, and just today, I asked it to spawn a subagent for a parallel task. The cursor blinked once, twice... then nothing. Honestly I'm used to it at this point but, for some reasons I was incredibly frustrated you see, and seeing my terminal was frozen solid and the UI being completely unresponsive...broke something in me.

If you've used Claude Code version on recent versions (2.1.23 and lower), you've probably experienced this. And I wanted to know why. (I was incredibly frustrated)

A note before we begin: I'm presenting this as cleanly as I can, but the actual investigation was messier. I went down several wrong paths - initially thought it was a React batching issue, then suspected memory pressure, then got lost in the async iterator implementation for a while. What follows is the cleaned-up version. I might still be wrong about some details, and I'd genuinely appreciate any corrections.


What You'll See

In this deep-dive, I'll walk you through:

  1. The ironic origin of this bug (spoiler: Claude probably caused it, fork found in kitchen)
  2. How to extract readable code from an 11MB minified bundle using AST analysis
  3. The exact technical root cause - and why setImmediate vs setTimeout matters more than we think
  4. A working fix verified through stress tests
  5. Lessons for AI-assisted development - where Claude (read llm to be general) excels and where it (they) fails

The Irony

Here's my tweet that started this investigation:

so it turns out, @AnthropicAI does not know how to yield

you all remember the spammy issue with the UI right, on claude code, which was explained in details by @SIGKITTEN before

so it still happens, but less, but now there is a new recent problem, where claude code just freeze

and this is because, they tried to use claude code to fix it, but knowing the failure mode of the thing, instead it used less yield statements in the code, now causing freezing of the UI instead

this is on version 2.1.23 btw https://x.com/secemp9/status/2016872881045581842?s=20 Let me unpack this.

The previous bug: Claude Code's UI was too spammy - rendering constantly, flickering, using excessive CPU. @SIGKITTEN mentioned this in detail.

The "fix": Someone used Claude to fix it. I just can't prove it.

James Doakes looking suspicious, used as a reaction image

what am I saying, this isn't speculation - Boris Cherny, who leads Claude Code development at Anthropic, has publicly documented that they use Claude code to build Claude Code. The fix reduced render spam by adding fewer yield points which is a very claude coded solution imo

The new bug: Actually worse, now there are far too few yields during long operations, causing the UI to freeze completely.

This is a perfect example of how most models/llm can understand a problem superficially ("too many renders → reduce renders") without grasping the nuance ("but you still need some renders during long operations"). Or maybe it's another instance of llm lazyness.


Extracting the Code

Claude Code is distributed as an npm package, but you can't just read the source - it's bundled into a single 11MB minified JavaScript blob. Here's how I extracted it.

Getting the Package

npm pack @anthropic-ai/claude-code --pack-destination .
tar -xzf anthropic-ai-claude-code-2.1.23.tgz

You now have package/cli.js - an 11.09 MB wall of minified code. Not really readable as it is. Unless you think outside the box (wdym there is no box?)

AST Splitting

I maintain a toolkit called ast-deobf-tools for exactly this situation. (It's held together with duct tape and prayer - I built it over several weekends for various reverse engineering projects, and it breaks on edge cases constantly. But it worked here.) The key tool is generic-dependency-splitter.js, which:

  1. Parses the entire bundle into an Abstract Syntax Tree using Babel
  2. Identifies the esbuild bundler pattern (lazy k() wrapper functions)
  3. Extracts each module into its own file
  4. Tracks dependencies between modules
  5. Creates a runtime bridge (__$) that wires everything back together

It's built to be as general as possible, and is also the reason why I learned that Babel actually does NOT do streaming on the nodes and instead hold everything into memory, but that's for another time.


how to use:

node generic-dependency-splitter.js ../package/cli.js ../output/split \
  --preserve-names --create-index

Output:

Parsing input file...
Found 4728 modules in bundle
Extracting modules...
Creating runtime bridge...
Writing index.js...
Done! Split into 4728 files

and there you go

Hash Verification

Here's the critical question: how do you know the split is correct?

Answer: hash the AST nodes.

I compute a structural hash of each function in the original bundle. After splitting, I compute the same hash for each extracted module. If they match, the split preserved the code exactly.

const originalHash = hashAST(originalBundle.modules['dZ1']);
const splitHash = hashAST(parsedSplitModule('modules/dZ1.js'));
assert(originalHash === splitHash, 'Split verification failed');

Final verification:

node ../output/split/index.js --version
# Output: 2.1.23 (Claude Code)

Identical behavior. The split worked.


The Smoking Gun

With readable code, I searched for subagent handling:

grep -r "subagent" modules/

The culprit: modules/dZ1.js - the Task tool that spawns subagents.

The Problematic Loop

Inside dZ1.js, there's a while(true) loop processing API messages:

while (!0) {
  // Get next message from async iterator...

  if (AA.type !== "message") continue;  // ❌ NO YIELD!

  let GA = wA.value;
  if (GA.type !== "assistant" && GA.type !== "user") continue;  // ❌ NO YIELD!

  // Expensive operations with NO YIELDS:
  let t = __$.lJ1(GA);  // JSON.stringify - up to 500ms
  let OA = __$.n2([GA]); // Object spread - up to 300ms

  // Callback loop with NO YIELDS:
  for (let t of OA) for (let XA of t.message.content) {
    if (Z) Z({...});  // Fire callback after callback after callback...
    // ❌ NO YIELD BETWEEN CALLBACKS!
  }
}

Four critical problems:

  1. continue statements skip yields entirely
  2. Expensive sync operations run back-to-back (500ms + 300ms = 800ms freeze)
  3. Hundreds of callbacks fire without any yields
  4. Where yields did exist, they used setImmediate instead of setTimeout

That fourth point is the subtle killer. Or at least, I think it is - this is the part where I'm least confident.


The Event Loop Problem

This section is my best attempt at explaining what I observed. If you know Node.js internals better than I do, I'd welcome corrections.

This is I believe, the core points:

Node.js processes callbacks in phases:

┌───────────────────────────┐
│ 1. timers      ← setTimeout callbacks execute here
│ 2. pending callbacks
│ 3. idle, prepare
│ 4. poll
│ 5. check       ← setImmediate callbacks execute here
│ 6. close callbacks
└───────────────────────────┘

React/Ink (Claude Code's terminal UI) uses a 32ms throttle implemented with setTimeout. Renders are scheduled in the timers phase.

My Initial (Wrong) Theory

I initially believed that setImmediate yields could "starve" the timers phase by running consecutively in phase 5. But that's not quite right - the event loop cycles through ALL phases each iteration: 1→2→3→4→5→6→1→... You can't get permanently stuck in one phase.

What's Really Happening

The key insight comes from this Stack Overflow discussion about setTimeout vs setImmediate performance:

setImmediate() x 914 ops/sec
setTimeout(,0) x 445 ops/sec

setImmediate is roughly 2x faster than setTimeout(0). This sounds good, but for UI responsiveness it's actually worse.

Here's why: setTimeout(0) has a minimum delay of ~1-4ms due to timer resolution. This "forced delay" is a feature, not a bug:

  1. With setImmediate: Your code yields and resumes almost instantly (~1ms). You dominate the CPU, giving React minimal time to actually execute renders.

  2. With setTimeout(0): Each yield takes ~1-4ms. This "breathing room" gives React's throttled render callbacks time to actually run.

The real bug: The original code had zero yields at all. Adding ANY yield would help. But setTimeout(0)'s minimum delay makes it a better choice than setImmediate because it gives React more breathing room between your operations.

This is still my best interpretation. The behavior matches, the fix works, but I acknowledge I could be wrong about the exact mechanism.


The Fix

I applied seven fixes. Some of these might be overkill - I was being paranoid after spending too long debugging:

setTimeout over setImmediate

// BEFORE (too fast - ~1ms, minimal breathing room):
await new Promise(r => setImmediate(r));

// AFTER (minimum ~1-4ms delay gives React time to render):
await new Promise(r => setTimeout(r, 0));

Yielding Before Heavy Work

await new Promise(r => setTimeout(r, 0));  // React renders
let t = __$.lJ1(GA);  // Then this can block

await new Promise(r => setTimeout(r, 0));  // React renders again
let OA = __$.n2([GA]); // Then this can block

Yielding on Continue

if (AA.type !== "message") {
  await new Promise(r => setTimeout(r, 0));  // NEW: Don't skip yields
  continue;
}

Batched Yields

Yielding every callback adds ~1ms each (100 callbacks = 100-400ms overhead). Batch instead:

let yieldCounter = 0;
for (let t of OA) for (let XA of t.message.content) {
  if (Z) Z({...});
  if (++yieldCounter % 16 === 0) await yieldWithAbortCheck();  // Every 16
}

Abort Signal Checks

const yieldWithAbortCheck = async () => {
  await new Promise(r => setTimeout(r, 0));
  if ($.abortController?.signal?.aborted) throw new AbortError();
};

O(n) over O(n²)

Found a bonus bug - repeated shift() calls are O(n²):

// BEFORE - O(n²):
while (A.recentActivities.length > 5) A.recentActivities.shift();

// AFTER - O(n):
if (A.recentActivities.length > 5) A.recentActivities = A.recentActivities.slice(-5);

Immutable Snapshots

normalizedMessages: [...e],  // Shallow copy prevents mutation races

Verification

"But did you break something else?"

Fair question. Honestly, I can't be certain I didn't introduce subtle regressions. The test coverage below is the best I could do without access to Anthropic's internal test suite:

Runtime Tests

setTimeout timing:      1.063ms avg per yield    Event loop cycling:     Callbacks execute        Abort detection:        Works correctly          Batched yields:         93.9% faster than naive  React render simulation: 22 renders occurred     

Stress Tests

1000 tool_use blocks:     1-2ms total            
Rapid abort/resume 100x:  All successful         
Nested agents 5 levels:   No stack overflow      
1MB JSON payload:         Handles correctly      
Memory leak detection:    -2.77% (no leaks)      

Functional Verification

node /output/split/index.js --version
# 2.1.23 (Claude Code)

node /output/split/index.js --help
# Full help output, all commands work

The patched version is functionally identical - except it doesn't freeze.


Definitive Evidence

This is the section for engineers who want to verify every claim. I built a test suite that analyzes the actual bundle code and measures real behavioral differences.

Original vs Patched

I extracted the Task tool region from the original 11MB bundle and compared it directly to the patched dZ1.js:

Metric Original Bundle Patched dZ1.js
setTimeout calls 0 2
await new Promise 0 2
yieldWithAbortCheck helper NO YES
Batched yields (% 16) NO YES
Abort signal checking NO YES
Shallow copy [...e] NO YES

The original Task tool code has zero yield points. The patched version has proper yields throughout.

Fix Verification

Each fix was verified by parsing the actual patched code:

 Fix 1: setTimeout instead of setImmediate
    setImmediate is ~2x faster than setTimeout(0) (914 vs 445 ops/sec)
    setTimeout(0)'s minimum ~1-4ms delay gives React breathing room to render
    See: stackoverflow.com/questions/24117267

 Fix 2: Yield helper with abort check
    Centralized helper: yieldWithAbortCheck()
    Checks $.abortController?.signal?.aborted after each yield

 Fix 3: Batched yields in loops (% 16)
    Yields every 16 iterations to balance responsiveness vs overhead
    100 callbacks = 6 yields instead of 100

 Fix 4: Shallow copy for race conditions
    normalizedMessages: [...e] instead of normalizedMessages: e
    Prevents mutation during async operations

 Fix 5: O(n) slice instead of O() shift
    while(arr.length > 5) arr.shift() is O()
    arr = arr.slice(-5) is O(n)

Behavioral Proof

Here's the smoking gun - actual event loop blocking measurements:

ORIGINAL pattern: 19ms elapsed, 0 React renders
PATCHED pattern:  391ms elapsed, 12 React renders

Expected renders at 32ms interval: ~12
Actual renders with patch: 12 (100%)
Actual renders without patch: 0 (0%)

The patched version takes longer because it actually yields to the event loop. But React gets 12 render opportunities instead of zero. That's the difference between a frozen UI and a responsive one.

(Note: The split version also has slightly slower startup time (~2.8s vs ~1.8s) because it loads 4,728 separate module files instead of one flat bundle. This is a limitation of the splitting approach for analysis, not the yield fixes. In production, the yield fixes could be applied to the original flat bundle with no startup penalty.)

The Yield Helper

This is the actual code added to dZ1.js:

const yieldWithAbortCheck = async () => {
  await new Promise(resolve => setTimeout(resolve, 0));
  if ($.abortController?.signal?.aborted) throw new __$.y2();
};

Simple, but critical. It: 1. Yields with a minimum ~1-4ms delay (gives React breathing room) 2. Checks for abort signals (responsive cancellation) 3. Throws the proper abort error type

Verify It Yourself

All claims can be verified. The investigation repo includes the original bundle, the full split version, and test scripts:

git clone https://github.com/secemp9/claude-code-ui-freeze-investigation
cd claude-code-ui-freeze-investigation
# 1. Run both versions
node original/cli.js --version     # Original: 2.1.23
node split/index.js --version      # Patched: 2.1.23 (identical)

# 2. Search for Task tool code in original
grep -n "subagent_type" original/cli.js
# → Found at position ~7.3MB into the bundle

# 3. Compare yield patterns
grep -c "setTimeout" original/cli.js              # Original: few
grep -c "setTimeout" split/modules/dZ1.js         # Patched: has yields

# 4. Run the test suite
node tests/definitive-comparison.js

Test Files

Three test scripts for independent verification:

File Purpose
freeze-comparison-test.js Simulates both patterns, measures React render opportunities
real-freeze-test.js Tests actual bundles, verifies fixes in real code
definitive-comparison.js Engineering-level evidence with code analysis

What This Teaches About AI

This bug might be a case study in AI limitations. I'm speculating here - I don't have access to the commit history or know who wrote what.

What I think Claude got right (if Claude was involved): - Understanding that "too many renders" was the complaint - Knowing that reducing renders would address that complaint

What I think went wrong: - The difference between setImmediate and setTimeout phases - That continue statements need yields too - That "reduce renders" doesn't mean "eliminate all yields" - The subtle interplay between React's scheduler and Node's event loop

This could also just be human error. Engineers make these mistakes too. But it does pattern-match to the kind of mistake I've seen AI make: optimizing for a metric ("fewer renders") without understanding the constraints ("but React still needs to run sometimes").

I could be totally wrong about the cause. But even if I am, the fix works, and maybe that's what matters.


Conclusion

Before the fix: 160-850ms+ UI freeze, unbounded, unpredictable

After the fix: Max ~500ms per operation, React guaranteed to render between operations

The patched code is included in the repo alongside this post. It passes all original tests plus my verification suite.

Total investigation effort: - 44 subagents across 11 rounds of verification - 4,728 modules extracted and analyzed - Event loop phases, React rendering, memory safety, concurrency - all verified


Files Changed

/output/split/modules/dZ1.js
  Lines 291-294:  yieldWithAbortCheck() helper
  Lines 357-359:  bgYieldWithAbortCheck() helper
  Lines 365, 369: Background batched yields
  Lines 404-407:  Non-message continue yield
  Lines 414-417:  Non-assistant/user continue yield
  Lines 419-420:  Yield before lJ1()
  Lines 427-428:  Yield before n2()
  Lines 432, 451: Batched inner loop yields (every 16)
  Line 442:       Shallow copy [...e]

/output/split/functions.js
  Line 39379:     OWA() O(n) slice fix

References

  • tool/generic-dependency-splitter.js - The AST-based bundle splitter I built for this investigation (included in this repo)
  • Babel - JavaScript parsing and AST manipulation
  • Node.js Event Loop - Official Documentation

Written after mass reverse engineering with Claude Opus 4.5 + 44 subagents. The irony of using Claude code to fix Claude code's bugs that was introduced with Claude code is not lost on me.


What Happens Next

Look, I know how this goes. Someone at Anthropic reads this, and the conversation becomes "how do we make the bundle harder to reverse engineer" instead of "how do we fix bugs faster."

Maybe they add heavier obfuscation. Maybe LLM-based detection. Maybe string hashing like they already do server-side to block alternative clients from Max subscriptions outside Claude code.

Here's the thing though: it's JavaScript. It has to run in a JS engine. The engine has to understand it. So the structure is always there - you're just hiding it under layers. AST analysis doesn't care if you rename functions to _0x4f2a. It sees the structure. And if obfuscation gets heavy enough that current models struggle, well, you can train a model specifically on obfuscated → clean pairs. The AST work I showed here would generate that training data pretty easily.

The thing is, if it can be run, then it can be parsed, and with someone with enough pluck, it is completely possible

This isn't me saying "I'm unbeatable" - it's just... the nature of client-side code. Someone will always be able to look at it.

The part I actually care about: Anthropic acquired Bun recently but stopped supporting and even preventing usage of their subscription outside of Claude code. OpenAI has Codex and fully support Opencode through their subscription services, among other alternative agent/client. Cursor and Windsurf are growing too and we even have Gravity, etc. The competitive landscape is real. And the moat was never in the tools - it's the trust, quality, and community.

I spent a good couple hours fixing this. That's not adversarial. That's what engaged users do. The question is whether that gets treated as a threat or as free QA.

Anyway, that's just my external take. I don't know what's happening internally, what constraints exist, what priorities are competing. Maybe there are good reasons for everything.

I just think it'd be a waste to go down the cat-and-mouse road when the alternative is right there: ship good software, fix bugs when people find them, build something developers actually want to use.

But hey, what do I know.


Corrections Welcome

I've tried to be thorough, but I'm one person working with obfuscated code and limited context. If you spot errors - technical mistakes, misunderstandings of the event loop, incorrect assumptions about Anthropic's architecture - please let me know. I'd rather be corrected than confidently wrong.

Also, just to be clear about what I don't have access to: the original source code, commit history, internal telemetry, or any context about why things were written the way they were. I'm working backwards from a minified bundle and observing behavior. Someone at Anthropic with actual access to the codebase could probably figure out way more than I did - like whether this was actually Claude-generated code, what the original intent was, and whether there are other affected code paths I missed. If you're that person and you're reading this: hi, sorry for the extra work, and I hope this at least points you in the right direction.

Last updated: January 2026

Continue the thread

Browse the archive, read more context, or reach out directly.

All posts About Email