Beş saniye önce çalışan bir yapılandırma dosyası, şimdi bozuk bir JSON'un 347 baytlık bir parçasına dönüştü. CLI aracınız başlatılamıyor. Güncelleme beklenenden uzun sürdüğü için sadece Ctrl-C tuşuna basan kullanıcı, şimdi istemediği bir hata yığını izlemesi (stack trace) ile karşı karşıya. Yazma işleminden önce var olan iki kilobaytlık geçerli yapılandırma yok oldu; yerini yarıda kesilmiş bir fişin dijital karşılığına bıraktı.
Bu durum, writeFile işleminin atomik olmamasından kaynaklanır. Mevcut yolu açar, içeriği kırpar (truncate), Node'dan çekirdeğin (kernel) sayfa önbelleğine (page cache) veri akışı sağlar ve sonunda dosya tanımlayıcısını (file descriptor) kapatır. Kırpma işlemi gerçekleştiği anda eski içerik çoktan gitmiş olur. Bu kırpma ile son close işlemi arasındaki her şey bir savunmasızlık penceresidir. Bu pencere sırasında gerçekleşen bir SIGINT, bir güç kesintisi veya bir dizüstü bilgisayar kapağının aniden kapanması, dosya sisteminin kırpılmış bir karmaşa ile kalmasına neden olur. Node, Promise'in çözüldüğünü (resolved) bildirse bile, işletim sistemi yazma işlemlerini hâlâ bellekte tamponluyor (buffering) olabilir. Kolaylık sağlayan yöntemler bu boşluğu gizler ancak ortadan kaldırmaz.
Çözüm, olduğu yere yazmak değildir. Çözüm, yazma eylemini yayınlama eyleminden ayırmaktır.
Bir yan dosyaya yazın, sonra değiştirin
Güvenilir desen beş adımdan oluşur. Hiçbiri karmaşık değildir ancak birlikte, hata penceresini tüm bir akışlı yazma işleminden tek bir dosya sistemi meta veri işlemine indirgerler.
İlk olarak, tüm yükü (payload) bellekte serileştirin. Bunu herhangi bir geçici dosya oluşturmadan önce yapın. Eğer birisi döngüsel bir nesne (circular object) gönderdiği için JSON.stringify hata verirse, diske dokunmadan önce bu istisnanın (exception) yukarı fırlatılmasını istersiniz.
İkinci olarak, serileştirilmiş veriyi hedefle aynı dizinde bulunan geçici bir dosyaya yazın. İki eşzamanlı çalışmanın çakışmaması için rastgele bir isim kullanın. Geçici dosyayı aynı dizinde tutmak önemlidir çünkü rename işlemi yalnızca tek bir dosya sistemi içinde atomiktir. Eğer geçici dosyanız farklı bir bölümde (partition) bulunuyorsa, işletim sistemi kopyala-ve-sil dizisine geri döner; bu da kendi hata modlarını beraberinde getirir ve artık atomik değildir.
Üçüncü olarak, çekirdekten bu geçici dosyayı fiziksel depolamaya boşaltmasını (flush) isteyin. Node'un burada bir dosya tutamacı (filehandle) üzerindeki sync metodu olarak sunulan fsync fonksiyonu, tamponlar fiziksel diske inene kadar işlemi engeller (blocks). Bu yavaştır, ancak yapılandırma yazma işlemleri yeterince nadir gerçekleştiği için bu dayanıklılık (durability) milisaniyelere değer.
Dördüncü olarak, geçici dosyanın adını orijinal yolun üzerine gelecek şekilde değiştirin (rename). Hem POSIX sistemlerinde hem de Windows'ta bu, onay (commit) noktasıdır. Orijinal yolu açan okuyucular ya tamamen eski dosyayı ya da tamamen yeni dosyayı görecektir. Bir okuyucunun yolu açıp yarıda yazılmış bir tamponla karşılaşabileceği hiçbir an yoktur.
Beşinci olarak, üst dizini senkronize edin (sync). Bu, ince bir uç durumu (edge case) yakalar. Yeniden adlandırma dizin kaydını günceller, ancak dizin meta verisinin kendisi çekirdeğin sayfa önbelleğinde kalabilir. Başarılı bir yeniden adlandırmadan sonra aniden meydana gelen bir güç kaybı, bazen dosya sistemini yeni inode referansının kalıcı olarak kaydedilmediği bir durumda bırakabilir. Dizini senkronize etmek, bu meta veri güncellemesini diske zorlar ve işlemi mühürler.
Somut bir Node.js uygulaması
İşte bu desenin yalnızca Node.js standart kütüphanesini kullanarak pratikteki görünümü:
import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";
export async function writeJsonAtomic(path, value) {
const directory = dirname(path);
const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
const body = `${JSON.stringify(value, null, 2)}\n`;
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(body, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, path);
const directoryHandle = await open(directory, "r");
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
Bu koddaki birkaç ayrıntıya dikkat edilmelidir.
wx bayrağı, "yaz ama dosya zaten varsa hata ver" anlamına gelir. Bu, bir UUID çakışmasına veya önceki çökmüş bir işlemden kalan terk edilmiş bir geçici dosyaya karşı koruma sağlar. Eğer birisi geçici dosyanızın olması gereken yere kötü niyetli bir dosya bıraktıysa, oradaki her şeyin üzerine yazmak yerine bunu hemen öğrenirsiniz.
0o600 izin maskesi, geçici dosyayı yalnızca sahibi tarafından okunabilir ve yazılabilir olacak şekilde oluşturur. Yapılandırma dosyaları sıklıkla gizli bilgileri, API jetonlarını (tokens) veya özel depo (repository) URL'lerini barındırır. Dosya hazırlanırken sistemdeki diğer kullanıcıların geçici dosyaya göz atmasına izin vermek için hiçbir neden yoktur.
Dosya üzerinde ve ardından dizin üzerinde yapılan ayrı sync çağrılarına dikkat edin. Birçok geliştirici, gereksiz göründüğü için dizin senkronizasyonunu atlar. Öyle değildir. Ext4, APFS ve NTFS'nin hepsi dizin güncellemelerini farklı şekilde işler, ancak performans için meta veri yazma işlemlerini gruplandırma (batching) konusunda ortak bir alışkanlığa sahiptirler. Güç kaybından sağ çıkmayı önemsiyorsanız, dizin senkronizasyonu son mühürdür.
The cleanup in the catch block is intentionally defensive. If anything throws after the filehandle is opened, the code attempts to close the handle and remove the temporary file, swallowing any secondary errors so the original exception propagates cleanly. You do not want a permission error during cleanup to mask the real bug that caused the failure.
Where this pattern stops helping
Atomic file replacement prevents torn writes. It does not prevent lost updates. If two instances of your CLI read the same config simultaneously, both perform their edits in memory, both write fresh temp files, and both execute their renames, the second rename wins. The first process did not observe the second process’s changes. Depending on your application, this could mean a user adds a setting in one terminal and a different user removes it in another, with the final file reflecting only the last writer.
If your tool needs to support concurrent mutators, you need a coordination mechanism on top of the atomic write. An advisory lock file works for simple cases. Version vectors or a monotonic revision number inside the config itself can help detect collisions so the second writer can retry. These add complexity, and complexity is where bugs hide.
That is why the boundary matters. A single JSON blob that one process updates occasionally is a fine candidate for an atomic file write. Once you find yourself managing multiple records, enforcing schemas, or worrying about concurrent mutations, you have outgrown the filesystem. SQLite exists for exactly this reason. It gives you atomic transactions, rollback journals, and proper handling of concurrent readers and writers, all inside a single host-local file. A clever file protocol is not a database, and you should not spend maintenance budget pretending otherwise.
The real takeaway
The next time you reach for writeFile inside a CLI tool, pause. Serialization is not the hard part. Durability is. Config files are too small to stream and too important to truncate. Write the entire payload to a hidden sibling, flush it, commit it with a rename, and tell the directory about it. Your users can press Ctrl-C, yank the power cord, or close their laptop lid. When the machine comes back, the file will either contain the old world or the new one. Nothing in between.
