Every storage engine ships with defaults that were tuned against a workload its authors imagined, and your workload is not that one, which is fine as long as the knobs exist and you can reach them without forking the engine, because a knob you cannot reach is not a knob, it is a hope. Embedded engines are usually the worst offenders here, since their tuning surface tends to be a pile of #ifdefs and a prayer, or a config file format invented for one release and abandoned by the next, so when we built the Kit layer we decided the tuning surface was part of the API and would be versioned like it. This post walks through the four knobs that actually move the needle on real deployments, what each one costs, and how you know which one to touch, because turning all four at once is how you end up bisecting your own config at 2am.
The whole set is a handful of one-line calls, database-wide or per-table, and every one of them takes effect on a live handle without a restart:
import { Database, IndexBuildPolicyJs } from "mongreldb-kit";
const db = Database.open("./store.kitdb");
// Database-wide: switch big commits to the pending-run path earlier.
db.setSpillThreshold(32 * 1024 * 1024);
// Per-table.
db.setTableMutableRunSpillBytes("events", 64 * 1024 * 1024);
db.setTableCompactionZstdLevel("events", 9);
db.setTableResultCacheMaxBytes("sessions", 64 * 1024 * 1024);
db.setTableIndexBuildPolicy("events", IndexBuildPolicyJs.Eager);
The spill pair: staged bytes and mutable runs
There are two spill thresholds and they guard two different things, and confusing them is the most common tuning mistake we see. The database-wide setSpillThreshold (default 64 MiB) decides what happens at commit time: when a single transaction’s staged bytes for one table stay under the threshold, the commit streams its rows through the normal Put path, and when they cross it, the engine instead writes the staged rows as a uniform-epoch pending run, a sorted file that lands on disk as one unit. The streamed path is cheaper for ordinary commits because it does no file layout work, and the pending-run path is cheaper for genuinely large commits because it skips the per-record machinery entirely, so the knob is really a switch for where your commit volume lives. If your application does occasional very large commits, lowering the threshold moves them onto the pending-run path sooner, where the commit writes one sorted file instead of walking every staged record, which is the cheaper shape once the commit is genuinely big. One honest caveat: the comparison happens at commit against bytes already staged in memory, so this knob trades commit-path cost, not peak staging memory, and if your biggest transaction is a few megabytes you should leave it alone, because the default was picked to keep ordinary OLTP-shaped commits on the fast path.
The second knob, setTableMutableRunSpillBytes (default 8 MiB, per table), sits downstream of the commit and controls when the in-memory mutable run drains to an immutable .sr sorted run on disk. Raise it and you coalesce more flushes in memory, which means fewer sorted runs accumulate, which means less merge work at read time and less work for compaction later; the cost is memory held per hot table. Lower it and memory stays flat but run count climbs, and run count is the number that punishes you at query time, because readers merge every run under MVCC and the compaction sweep has more to chew on. The feedback loop here is tableRunCount, since one run per table is the compaction target and the daemon’s background sweep kicks in at eight, so if a table’s run count keeps climbing between sweeps, its mutable-run spill is probably set too small for its write rate.
Compaction zstd: a knob with a self-describing page format behind it
setTableCompactionZstdLevel takes -1 for the default, 0 for no compression, or any level from 1 to 22, and the default resolves to zstd level 3. The reason this knob is safe to change on a live table is the page format: every compressed page carries an algorithm prefix byte and the decoder is fully self-describing, so a single run may freely mix pages written under any variant, and old data stays readable no matter what you set next. The engine already exploits this internally, because the level-1 bulk path writes zstd level 1 (three to four times faster compression at roughly ten percent worse ratio) and background compaction upgrades cold runs to the compaction level, which is level 3 out of the box and whatever you set it to once you turn the knob, while hot mutable runs that expect to be scanned use LZ4 instead, trading that same ten percent of ratio for a three-to-five-times faster decode.
So the honest guidance is narrow: push the level up on tables that are write-once and read-rarely, like audit archives and event history, where compaction runs once and the bytes sit for months; push it down or to zero on tables where compaction time is the bottleneck and disk is cheap; and leave the default on anything mixed, because level 3 is the point where the curve is still mostly flat in ratio and already bending in CPU. Level 19 and above is for people who have measured, and if you have not measured, you are not those people yet.
The result cache budget
Every table carries a result cache with a default budget of 256 MiB, evicted in access-order LRU, and setTableResultCacheMaxBytes changes that budget immediately, evicting entries on the spot if the new limit is smaller than the current footprint. Two details matter more than the headline number. First, the budget is per table, not per database, so a wide join-heavy schema can hold several times the default in aggregate and you should size the hot tables deliberately rather than letting every table assume 256 MiB of headroom. Second, entries under 4 KiB never touch the persistent tier, they stay memory-only so that a warm miss on a one-row point query does not pay for an atomic filesystem publish, but they still count against the byte budget and still get evicted under LRU like anything else, so treat that 4 KiB line as a persistence detail rather than a memory exemption. Tune this knob for the analytical tables, where a cached aggregation might be megabytes, and verify the change honestly: Kit’s introspection covers the page cache (tablePageCacheStats) rather than a result-cache hit counter, so the loop here is the latency of your repeated analytical queries watched before and after, with page-cache stats as the supporting signal for whether reads hit warm pages at all.
Index build policy: deferred versus eager
The default policy, Deferred, builds secondary indexes lazily at the first query or flush that needs them, which makes bulk ingest as fast as the engine can go because the load path does no index work at all. Eager flips that: indexes are built and checkpointed inside the bulk load itself, so the first query after a big ingest does not stop to construct an index before it can answer. The right choice is entirely about who pays for the index and when, since the total work is roughly the same either way. A nightly ETL job that reloads a reporting table and then immediately serves dashboards wants Eager, because the ingest window has slack and the first dashboard query does not; a telemetry table that ingests all day and gets queried on demand wants Deferred, because ingest latency is the product and the index can build whenever it builds. Either way, large index builds run as durable online jobs with an id you can resume, cancel, or wait on, so a crash mid-build costs you a resume call rather than a rebuilt table.
How to know which knob to touch
The tuning loop is the boring one that works, and it starts from the introspection calls we covered in the per-table introspection post: watch tableRunCount for run accumulation, tablePageCacheStats for hit rates, and tableMemtableLen for staging pressure, then change one knob and watch again, because the defaults cover the median case and your job is only to fix the table that is not median. If you want a baseline to compare against, the published one-million-row numbers put typed bulk load at about 17 million rows per second and plain batch ingest at about 1.2 million rows per second committed, both Criterion central estimates measured on one Intel Core Ultra 9 machine in July 2026, so treat them as a smell test rather than a promise: if your tuned pipeline is orders of magnitude off those figures, the problem is probably the pipeline and not the knobs. The older way of doing this was recompiling the engine with different constants and hoping you remembered which ones, and the modern equivalent is four setter calls and a run counter, which is less romantic and considerably easier to undo.