Race condition: .claude.json corruption when running multiple instances concurrently

Resolved 💬 27 comments Opened Feb 26, 2026 by HardRockTech Closed Feb 26, 2026

Description

Running multiple Claude Code instances simultaneously causes repeated .claude.json corruption due to concurrent writes without file locking or atomic write operations.

Environment

  • Claude Code version: 2.1.59 (also observed on earlier versions back to at least ~2.1.56)
  • OS: Windows 11 Pro 10.0.26200
  • Shell: Windows Powershell

Steps to Reproduce

  1. Open 5+ terminal windows
  2. Run claude in each
  3. Use them concurrently for normal work
  4. Observe repeated corruption warnings on startup and during sessions

Observed Behavior

Each instance detects the corruption, backs up the broken file, and recovers — but this produces:

  • Hundreds of .claude.json.corrupted.* backup files in ~/.claude/backups/ (305 files accumulated in a single day)
  • Spam warning messages on every startup:

``
Claude configuration file at C:\Users\<user>\.claude.json is corrupted: JSON Parse error: Unexpected EOF
The corrupted file has been backed up to: C:\Users\<user>\.claude\backups\.claude.json.corrupted.<timestamp>
``

  • The corrupted files show truncated JSON (partial writes caught mid-stream)

Expected Behavior

Multiple concurrent instances should be able to coexist without corrupting the shared config file. No warning spam, no backup file accumulation.

Suggested Fix

_Redacted by Claude Code maintainers to avoid confusion as users find this issue_.

Impact

  • Not data-losing — Claude Code recovers gracefully each time
  • Annoying — constant warning messages disrupt workflow
  • Disk clutter — hundreds of small backup files accumulate rapidly
  • Legitimate workflow — running multiple instances in parallel (e.g., one per project/repo) is a common power-user pattern that should be supported

View original on GitHub ↗

27 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/28813
  2. https://github.com/anthropics/claude-code/issues/28829
  3. https://github.com/anthropics/claude-code/issues/28824

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

georgiai1 · 4 months ago

Very severe issue.

Once you open claude code and if has update, close it right away and run this, check version after each launch
claude install 2.1.50

Disabling update with env variable doesnt work for me

villanibr · 4 months ago

Same here. Almost impossible to work with multiple instances of Claude Code at the same time.

zleo77818 · 4 months ago

I am also encountering the same problem, it is significantly affecting my work, and I am very anxious.

enricoros · 4 months ago

Thanks @georgiai1 the downgrade works. This week Claude Code has been terrible. Regressions, bugs, asks for confirmations 100 times in any task, does not follow Claude.md instructions...

stevenpetryk · 4 months ago

Update 4:10PM: We have escalated this to a hotfix and we are working on deploying it as quickly as possible.

---

Update 2:47PM ET: We believe we've found the smoking gun and are planning to have a fix in today's scheduled release. I will keep you all posted. Apologies for the inconvenience here.

---

Update 1:55PM ET: We may need to address this with today's scheduled release. We believe the fact that this corrupts config writes prevents our feature flag changes from propagating for a number of users.

---

Update 1:47PM ET: There are some ongoing reports of this issue, and our investigation continues.

---

Update 12:39PM ET: we have remotely disabled a non-essential feature that we believe was contributing to a significant number of config writes. This should prevent almost all contention.

---

Update 12:21pm ET: we think we have a root cause for why writes to the global config increased so much, and are working on a mitigation. We are not yet sure if this will require a deploy. Thanks for your patience. In the meantime, we are also going to take a holistic look at our config read-write concurrency management to try to prevent this in the future.

---

Hey folks, can confirm this behavior and I'm working on a fix. Thanks for reporting. I'm arbitrarily considering this the canonical issue for this problem and will close duplicates after resolving.

This issue (and its near-duplicates) have revealed several vicious-cycle feedback loops in how Claude Code reads/writes its config that I am going to try to thoroughly address. These issues are especially present on Windows which has different file locking strategies than POSIX-compliant systems.

brzbnzai289 · 4 months ago

Confirming this on Windows 11, Claude Code 2.1.59 (native binary), Opus 4.6.

Running 2 concurrent sessions from Git Bash. Got the corruption error twice within minutes of each other — restored from backup the first time, then got the same error again in a new session immediately after.

The corrupted file was valid JSON but stripped down to ~27 lines (missing oauthAccount, tipsHistory, toolUsage, cachedGrowthBookFeatures, etc.) with a different userID hash — consistent with a session writing a fresh minimal config after failing to parse the file mid-write by another session.

Backup at ~/.claude/backups/ had the full 163-line config intact. Manual cp restore works but this is happening frequently enough to need an alias for it.

villanibr · 4 months ago

@stevenpetryk Sir, can I pet that dawg?

stevenpetryk · 4 months ago
@stevenpetryk Sir, can I pet that dawg?

Ah, this is a common misconception: I actually _am_ the dog in that picture, and the man is just somebody who wanted to pose with me. Claude Code lets me code without opposable thumbs though, so all is well.

stevenpetryk · 4 months ago

Removing platform:windows because this behavior is technically reproducible on all platforms, but Windows just has especially prone-to-deadlock filesystem behavior.

stevenpetryk · 4 months ago

Alright gang, we've remotely disabled a non-essential feature that we believe was causing excessive config writes. How do things seem? (Restarting Claude Code is recommended, but no update is needed).

ThatDragonOverThere · 4 months ago

Cross-posting from #28809 (closed as duplicate of this issue) to preserve root cause analysis and evidence.

Evidence from 32 days of tracking

Scale: 278+ corruption events in a single day (Feb 25), 36 Bun crashes in 32 days across v2.1.50→59, 4 full computer lockups.

Root Cause Analysis

We identified three compounding failure modes, not just concurrent writes:

1. UTF-8 BOM Corruption

Bun's JSON parser rejects valid .claude.json files that have a UTF-8 BOM (\xef\xbb\xbf) prepended. When Claude Code encounters this, it declares the file "corrupted" and triggers the backup/restore cycle — even though the JSON content is perfectly valid. A simple encoding='utf-8-sig' read would handle this silently.

2. Non-Atomic Writes (the race condition you identified)

.claude.json is written in-place without:

  • Temp file + rename pattern
  • fsync() before rename
  • File locking (flock/LockFileEx)

This means ANY interruption during a write (crash, concurrent session, antivirus lock) produces a partial/empty file.

3. Auto-Recovery Death Spiral (the worst part)

When Claude Code detects corruption, it:

  1. Backs up the corrupted file to ~/.claude/backups/.claude.json.backup.*
  2. Creates a new .claude.json with a 234-byte skeleton (just {"version":1} essentially)
  3. Next crash: backs up the skeleton as a "good" backup
  4. Now ALL backups are skeletons
  5. The suggested restore command (cp backup... .claude.json) restores a skeleton, wiping all permissions, auth tokens, and settings

The recovery mechanism is actively harmful — it replaces corrupted-but-recoverable configs with empty skeletons, then poisons the backup pool with those skeletons.

The Fix

Standard pattern used by every serious config manager:

1. Write to .claude.json.tmp
2. fsync(.claude.json.tmp)
3. rename(.claude.json.tmp -> .claude.json)  // atomic on all OS
4. Use LockFileEx (Windows) or flock (Unix) for concurrent access
5. Validate backup > MIN_SIZE before considering it "good"

Workaround

We built a watchdog script that monitors .claude.json every 30s and restores from a known-good "golden backup" stored in git. It catches ~10 corruptions per hour during active use. This should not be necessary.

Evidence threads: #28809 (closed), #21576 (36 crash repros), #21875 (WinDbg analysis), #29050 (single-session corruption)

stevenpetryk · 4 months ago

@ThatDragonOverThere thank you, but rest assured all duplicates have been incorporated into our analysis.

stevenpetryk · 4 months ago

There are some ongoing reports of this issue, and our investigation continues. ~~We may need to fix this in the 2.1.60 release scheduled for today. Sorry again for the disruption.~~ Edit: we are going to hotfix this, which means it will likely go out as 2.1.61.

Nantris · 4 months ago

I have to re-auth in every terminal, every instantiation of claude even in the same terminal. It's brutal.

stevenpetryk · 4 months ago

We believe we've found the smoking gun and are planning to have a fix in today's scheduled release. I will keep you all posted. Apologies for the inconvenience here.

stevenpetryk · 4 months ago

We are escalating this to a hotfix to get it remediated faster. I have also created a status page incident—apologies, I should have done that earlier.

viftode4 · 4 months ago

Root Cause Analysis (decompiled source)

Decompiled the bundled JS from the binary and traced the exact code path:

1. writeFileAtomic — the non-atomic fallback

The write function correctly uses temp file + renameSync. But on Windows, renameSync fails with EPERM when another process has the target open. The code falls back to a direct writeFileSync:

function writeFileAtomic(path, data) {
  let tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
  try {
    fs.writeFileSync(tmp, data, { flush: true });
    fs.renameSync(tmp, path);  // FAILS on Windows with EPERM
  } catch {
    // BUG: falls back to non-atomic write
    fs.writeFileSync(path, data, { flush: true });
  }
}

2. saveGlobalConfig — lock failure fallback

Uses proper-lockfile with lockSync(). When locking fails, saveGlobalConfig catches the error and falls back to a completely unlocked, non-atomic write:

function saveGlobalConfig(callback) {
  try {
    saveWithLock(configPath, defaults, callback);
  } catch {
    saveWithoutLock(configPath, mergedConfig, defaults);  // no lock, no atomicity
  }
}

3. The cascade

  1. Session A reads config, Session B reads config
  2. Session A writes (rename fails EPERM) -> falls back to direct writeFileSync
  3. Session B writes simultaneously -> truncates A's half-written file
  4. Session C reads truncated JSON -> Unexpected EOF
  5. Corruption handler writes minimal config (loses oauthAccount)
  6. All sessions need re-login -> more writes -> more corruption

Suggested fixes

  1. Don't fall back to non-atomic writes. Retry rename with backoff, or use fs.copyFile + fs.unlink.
  2. Use File.Replace semantics on Windows — the native atomic file swap.
  3. Make tengu_config_stale_write abort the write — it already detects stale writes but only logs telemetry.
  4. Separate volatile and stable config — move toolUsage, clientDataCache, cachedGrowthBookFeatures to a separate file to reduce contention on auth config.

---

Workaround: Config Guard (PowerShell, tested)

Polling-based background script that auto-repairs corruption and restores oauthAccount. Verified on Windows 11 + PowerShell 7 + .NET 9.

  • Polls every 500ms (cheap mtime check, reads only on change)
  • On corruption: atomically restores from in-memory snapshot via File.Replace
  • On auth loss: splices oauthAccount back via string manipulation (no ConvertTo-Json round-trip)
  • Persists auth to ~/.claude/guard-auth-cache.json (survives terminal restarts)
  • Single-instance via PID file, never falls back to non-atomic writes

<details>
<summary><b>guard.ps1</b> (save to <code>~/.claude/guard.ps1</code>)</summary>

$pidFile = "$env:USERPROFILE\.claude\guard.pid"
$configPath = "$env:USERPROFILE\.claude.json"
$backupDir = "$env:USERPROFILE\.claude\backups"
$logFile = "$env:USERPROFILE\.claude\guard.log"
$authCacheFile = "$env:USERPROFILE\.claude\guard-auth-cache.json"
$pollMs = 500

if ([System.IO.File]::Exists($pidFile)) {
    try {
        $existingPid = [int][System.IO.File]::ReadAllText($pidFile).Trim()
        $proc = Get-Process -Id $existingPid -ErrorAction SilentlyContinue
        if ($proc -and $proc.ProcessName -like "*pwsh*") { return }
    } catch {}
}
[System.IO.File]::WriteAllText($pidFile, "$PID")

$lastGoodSnapshot = $null
$lastAuthSnapshot = $null
$lastMtime = [DateTime]::MinValue
$script:guardWriteTime = [DateTime]::MinValue

function Write-Log($msg) {
    $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
    "$ts  $msg" | Add-Content -Path $logFile -ErrorAction SilentlyContinue
}

function Write-ConfigAtomic($content) {
    $tmp = "$configPath.guard.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())"
    try {
        [System.IO.File]::WriteAllText($tmp, $content)
        if ([System.IO.File]::Exists($configPath)) {
            $bak = "$configPath.guard-replace-bak"
            [System.IO.File]::Replace($tmp, $configPath, $bak)
            try { [System.IO.File]::Delete($bak) } catch {}
        } else { [System.IO.File]::Move($tmp, $configPath) }
        $script:guardWriteTime = Get-Date
        return $true
    } catch {
        try { [System.IO.File]::Delete($tmp) } catch {}
        Write-Log "Atomic write failed (will retry): $_"
        return $false
    }
}

function Test-ValidJson($text) {
    if ([string]::IsNullOrWhiteSpace($text)) { return $false }
    try { $null = $text | ConvertFrom-Json -ErrorAction Stop; return $true } catch { return $false }
}

function Get-LatestBackup {
    Get-ChildItem -Path $backupDir -Filter ".claude.json.backup.*" -ErrorAction SilentlyContinue |
        Sort-Object Name -Descending | Select-Object -First 1
}

function Save-AuthCache($content) {
    try { [System.IO.File]::WriteAllText($authCacheFile, $content) } catch {}
}

function Load-AuthCache {
    try {
        if ([System.IO.File]::Exists($authCacheFile)) {
            $c = [System.IO.File]::ReadAllText($authCacheFile)
            if ((Test-ValidJson $c) -and $c.Contains('"oauthAccount"')) { return $c }
        }
    } catch {}
    return $null
}

function Merge-AuthIntoJson($targetJson, $authJson) {
    $fields = @('"oauthAccount"','"hasCompletedOnboarding"','"claudeCodeFirstTokenDate"',
                '"firstStartTime"','"lastOnboardingVersion"','"opusProMigrationComplete"',
                '"sonnet1m45MigrationComplete"')
    $result = $targetJson
    foreach ($field in $fields) {
        if ($result.Contains($field) -or -not $authJson.Contains($field)) { continue }
        $fe = [regex]::Escape($field)
        if ($field -eq '"oauthAccount"') {
            $m = [regex]::Match($authJson, "$fe\s*:\s*\{")
            if (-not $m.Success) { continue }
            $s = $m.Index; $d = 1; $p = $m.Index + $m.Length
            while ($d -gt 0 -and $p -lt $authJson.Length) {
                if ($authJson[$p] -eq '{') { $d++ } elseif ($authJson[$p] -eq '}') { $d-- }; $p++
            }
            $snippet = $authJson.Substring($s, $p - $s)
        } else {
            $m = [regex]::Match($authJson, "$fe\s*:\s*(?:`"[^`"]*`"|\d+(?:\.\d+)?|true|false|null)")
            if (-not $m.Success) { continue }
            $snippet = $m.Value
        }
        $lb = $result.LastIndexOf('}'); if ($lb -lt 0) { continue }
        $bb = $result.Substring(0, $lb).TrimEnd()
        $c = if ($bb[-1] -ne ',' -and $bb[-1] -ne '{') { "," } else { "" }
        $result = $result.Substring(0, $lb) + "$c`n  $snippet`n" + $result.Substring($lb)
    }
    return $result
}

$lastAuthSnapshot = Load-AuthCache
try {
    $raw = [System.IO.File]::ReadAllText($configPath)
    if (Test-ValidJson $raw) {
        $lastGoodSnapshot = $raw
        $lastMtime = [System.IO.File]::GetLastWriteTimeUtc($configPath)
        if ($raw.Contains('"oauthAccount"')) { $lastAuthSnapshot = $raw; Save-AuthCache $raw }
        Write-Log "Guard started (PID $PID). Valid ($($raw.Length) chars), auth: $($null -ne $lastAuthSnapshot)"
    } else { Write-Log "Guard started (PID $PID). Config corrupted." }
} catch { Write-Log "Guard started (PID $PID). Read failed: $_" }
Write-Log "Polling every ${pollMs}ms"

try {
    while ($true) {
        Start-Sleep -Milliseconds $pollMs
        try {
            if (((Get-Date) - $script:guardWriteTime).TotalMilliseconds -lt 1500) { continue }
            try { $cm = [System.IO.File]::GetLastWriteTimeUtc($configPath) } catch { continue }
            if ($cm -eq $lastMtime) { continue }; $lastMtime = $cm
            try { $content = [System.IO.File]::ReadAllText($configPath) } catch { continue }
            if (Test-ValidJson $content) {
                $lastGoodSnapshot = $content
                if ($content.Contains('"oauthAccount"')) { $lastAuthSnapshot = $content; Save-AuthCache $content }
                elseif ($lastAuthSnapshot) {
                    $merged = Merge-AuthIntoJson $content $lastAuthSnapshot
                    if ($merged -ne $content -and (Test-ValidJson $merged) -and (Write-ConfigAtomic $merged)) {
                        $lastGoodSnapshot = $merged
                        Write-Log "AUTH RESTORED ($($content.Length)->$($merged.Length) chars)"
                    }
                }
            } else {
                Write-Log "CORRUPTION ($($content.Length) chars)"
                $snap = if ($lastAuthSnapshot) { $lastAuthSnapshot } else { $lastGoodSnapshot }
                $ok = $false
                if ($snap -and (Write-ConfigAtomic $snap)) { Write-Log "RESTORED ($($snap.Length) chars)"; $ok = $true }
                if (-not $ok) {
                    $bu = Get-LatestBackup
                    if ($bu) { try { $bc = [System.IO.File]::ReadAllText($bu.FullName)
                        if ((Test-ValidJson $bc) -and (Write-ConfigAtomic $bc)) {
                            $lastGoodSnapshot = $bc
                            if ($bc.Contains('"oauthAccount"')) { $lastAuthSnapshot = $bc; Save-AuthCache $bc }
                            Write-Log "RESTORED from $($bu.Name)"; $ok = $true
                        } } catch {} }
                }
                if (-not $ok) { Write-Log "FAILED to restore." }
            }
        } catch { Write-Log "ERROR: $_ $($_.ScriptStackTrace)" }
    }
} finally {
    try { if ([System.IO.File]::ReadAllText($pidFile).Trim() -eq "$PID") { [System.IO.File]::Delete($pidFile) } } catch {}
    Write-Log "Guard stopped (PID $PID)"
}

</details>

Auto-start — add to pwsh $PROFILE:

$_guardScript = "$HOME\.claude\guard.ps1"
if (Test-Path $_guardScript) {
    $existing = Get-Job -Name "ClaudeConfigGuard" -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Running' }
    if (-not $existing) {
        Get-Job -Name "ClaudeConfigGuard" -ErrorAction SilentlyContinue | Remove-Job -Force -ErrorAction SilentlyContinue
        Start-Job -Name "ClaudeConfigGuard" -FilePath $_guardScript | Out-Null
    }
}

Requires PowerShell 7+ (pwsh) for File.Replace.

stevenpetryk · 4 months ago

Just to keep y'all in the loop, we continue to work on getting this deployed.

Nantris · 4 months ago

Surprisingly today it's been pretty okay despite no change in Claude Code version and continuing the same sessions from yesterday that we're having constant corruption. Starting new sessions also worked without corruption. Just wanted to share in case this is surprising to the maintainers.

stevenpetryk · 4 months ago

Thanks @Nantris — this issue is mostly prevalent when people are multi-Claude drifting, single sessions are probably fine.

stevenpetryk · 4 months ago

All, we've released 2.1.61 which should fix this problem. Please run claude update to get the latest.

This was a disruptive error, so I wanted to share the action items we are going to take:

  • We are going to address the underlying race condition that led to processes getting partial reads/EOF. (The hotfix in 2.1.61 only fixes a bug that caused excess writes.)
  • We are going to alert on the rate of config corruption events (we had analytics, but they did not trigger paging alarms).
  • We are going to add an employee-only feature that exposes excess config writes directly in the UI (this won't be public, but it'll help us catch this category of issue before release).

Thank you so much for your patience here.

ThatDragonOverThere · 4 months ago

@stevenpetryk — thank you for the incident response today and for shipping v2.1.61. First meaningful engagement in 33 days of reporting. The config corruption fix is confirmed working.

However: the Bun crash that CAUSES the config corruption is still unfixed. Repro 49 was on v2.1.61 — same TUI escape, same [I[O[ codes. 30 crashes today alone.

The user has switched from the native Bun binary to npm install (Node.js runtime) as a workaround. If the crashes stop on Node.js, that conclusively proves the issue is in Bun's N-API bridge, not in Claude Code's application code.

The root cause analysis is on #21875 (WinDbg dumps, vtable corruption) and oven-sh/bun#27471 (comprehensive Bun bug filed today with 49 repros). Both have zero responses.

The config fix was the right move. The next step is acknowledging the Bun crash itself — #21576 has 49 repros with zero Anthropic staff comments.

TheJesper · 4 months ago

If Claude commands hang after this issue, check whether ~/.claude.json is marked read-only. On Windows, removing the read-only attribute from C:\Users\<user>\.claude.json fixed it for me.

#claude #hanging #freeze #hangs #terminal

junaidtitan · 4 months ago

Running multiple Claude instances is what led us to build cozempic — the session detection was the first problem we hit, and .claude.json corruption was next.

\cozempic doctor\ now detects corrupted config files and session files and can auto-repair them:

\\\
pip install cozempic
cozempic doctor # detect issues
cozempic doctor --fix # auto-repair with backup
\
\\

Also added a \--session\ flag to the guard daemon so you can target a specific session by ID instead of relying on auto-detection (which breaks with multiple instances):

\\\
cozempic guard --daemon --session <session-id>
\
\\

Open source — feedback welcome if anyone's running multi-instance setups.

ThatDragonOverThere · 4 months ago

Auto-Updater Is Itself a Vector for Breaking User Configurations

Related to the Bun crash issue (#21576): the auto-updater silently downgraded me from v2.1.62 (Node.js npm install) to v2.1.61 (Bun native binary) overnight on Mar 3 by re-downloading the native binary to ~/.local/bin/claude.exe.

This is relevant to config corruption because:

  1. The auto-updater overwrites the user's deliberate runtime choice — I had intentionally switched to Node.js to avoid Bun crashes. The updater silently put me back on a different runtime without consent or notification.
  2. It downgraded rather than upgraded — going from v2.1.62 to v2.1.61, meaning whatever config-related fixes existed in v2.1.62 were reverted.
  3. There is no way to disable auto-updatesautoUpdatesChannel only accepts "latest" or "stable", with no "disabled" or "none" option. Users cannot protect their configurations from being overwritten by the updater.

The auto-updater making silent, non-consensual changes to the installed binary is a config corruption vector in its own right. If it can silently swap the entire runtime binary, user-side configuration stability is fundamentally undermined.

github-actions[bot] · 4 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.