An append to a file in Node, by default, is not durable. fs.appendFileSync writes the bytes to the kernel's page cache and returns. The kernel decides when to flush the page cache to actual storage, on its own schedule, which can be tens of seconds later. A SIGKILL or power loss between the return and the flush loses the entry — the application has every reason to believe the write happened, but the disk disagrees.

For most logs this doesn't matter. Application logs, debug traces, request logs — losing a second or two of tail entries on a crash is acceptable, and the cost of fsync on every write would slow them down too much to be worth it.

For an audit log that records every write attempt against a legal system of record, it matters. The audit log exists specifically because the application might crash, the API might reject, the network might drop. The entries are forensic. If the entry isn't on disk when the process dies, the forensics fail in exactly the moment they're supposed to succeed.

The fix

Replace appendFileSync with a four-line helper:

function durableAppend(path, line) {
  const fd = openSync(path, "a", 0o600);
  try {
    writeSync(fd, line);
    try { fsyncSync(fd); } catch { /* best-effort on tmpfs/NFS */ }
  } finally {
    closeSync(fd);
  }
}

The fsync call asks the kernel to flush both the data pages and the inode metadata for this file's open file descriptor to durable storage before returning. After fsyncSync returns, a power loss won't lose the byte. The cost is a forced disk write — milliseconds, not microseconds. For an audit log that fires once per write operation (not once per line of debug output), that cost is negligible.

The catch around fsync is for filesystems that don't support it. tmpfs returns ENOSYS. Some NFS configurations return EINVAL. Both are environments where you wouldn't trust durability anyway, so swallowing the error is the right call — the layered guarantee just falls back to whatever the underlying storage offers.

The other half of the gap

The audit log has a two-line shape for writes: a pre-flight "in_flight" intent entry written before the network call, and a post-flight "accepted" or "rejected" outcome entry written after the response. The pair lets a post-mortem reconstruct what was attempted vs. what completed.

If the process is killed between the two writes — by SIGTERM from the OS, SIGHUP from a closing terminal, or SIGINT in non-interactive mode — only the intent entry exists. The audit log says "this write started" with no follow-up. A reader can't tell whether the write completed server-side, was rejected, or never got off the wire.

The fix is a signal handler that writes a third outcome: "interrupted." A module-scope variable holds the in-flight write's details (endpoint, fields) while the fetch is pending. The signal handler reads it and, if non-null, writes an audit entry with api_error: "interrupted by SIGTERM mid-flight" before exiting. The outcome line lands. Post-mortem can distinguish all three cases: never tried, tried and completed, tried and killed.

The handler covers SIGINT, SIGTERM, and SIGHUP because each one has a legitimate non-malicious source. SIGTERM is what systemd sends on shutdown. SIGHUP is what happens when ssh disconnects mid-command. SIGINT is what happens if the shell forwards a Ctrl-C to a non-interactive child. None of them should leave the audit log inconsistent.

The double-log problem

The signal handler runs in addition to the normal outcome handler — it doesn't replace it. If the outcome is already written, the handler shouldn't write a second contradictory entry. The way I solved this: the audit-write function clears the in-flight variable whenever it writes a non-intent outcome. So after "accepted" lands, the in-flight variable is null. If the signal then fires, the handler sees null and writes nothing.

This is a one-line synchronization in single-threaded JavaScript — Node's event loop guarantees that the audit write and the in-flight clear are atomic with respect to the signal handler, because signal handlers run between event-loop turns, not interleaved with synchronous code. If the codebase were multi-threaded, this would need a mutex; in Node it's a free guarantee.

Why this took 89 releases to find

The fsync gap was the easier one to miss. appendFileSync is the obvious-looking API. It's named "Sync." It throws on error. It looks durable from the calling code. The "Sync" only refers to the lack of an async callback — it has nothing to do with the kernel's page-cache state. To know the difference you have to know that fsync exists and what it does, which is one level deeper than the API name suggests.

The signal-handler gap was a different kind of missing thing. The code worked correctly on the happy path and on every error path it handled — network errors, API rejections, malformed bodies, redirect failures. Every code path the developer considered was covered. The signal gap was a path the developer didn't consider: "what if the process dies between these two lines?" That path doesn't exist in the source code. It only exists in the runtime, and only because of an external event. You have to deliberately probe for it.

The general lesson: an audit log is a durability claim, and durability claims have to be verified at the level of the storage layer, not the application API. "I called the write function and it returned" is not durability. "I called the write function, it returned, and an fsync has confirmed the bytes are on disk" is durability. Everything else is hope.

The version on the install URL is 4.5.89.