The Problem
The Pretense proxy sits in the hot path of every LLM API request a developer makes. Every code completion, every refactoring request, every multi-file analysis passes through it. That makes latency a core product requirement.
In v0.1, the scanner was written in TypeScript. It worked. It passed all tests. It caught secrets and mutated identifiers correctly.
It was also adding approximately 50ms to every request.
For a single code completion, 50ms is barely perceptible. For a multi-turn Claude Code session where dozens of requests fire in quick succession, 50ms per request accumulates into 2-3 seconds of visible delay. Developers noticed. In user testing, we heard the same feedback repeatedly: "it feels a little laggy."
We needed to get well under 50ms.
Understanding Where the Time Went
Before rewriting anything, we profiled the TypeScript scanner to understand the actual bottleneck.
// TypeScript scanner hot path (simplified)
export function scanCode(source: string): ScanResult {
const tokens = tokenize(source); // ~15ms on 1000 tokens
const identifiers = extractIdentifiers(tokens); // ~8ms
const mutations = identifiers.map(mutate); // ~12ms
const secrets = detectSecrets(source); // ~18ms (32 patterns)
return { tokens, mutations, secrets };
}
The profiler showed:
- -Regex compilation: 22ms (the secret detection regexes were being recompiled on every call)
- -String allocation: 14ms (JavaScript's garbage collector pressure from creating thousands of string objects)
- -Single-threaded execution: 0ms overhead, but 0ms parallelism
The regex compilation was fixable in TypeScript (cache compiled regexes). We fixed it. It brought average time down to ~28ms. Still too slow for the hot path.
The string allocation problem was inherent to V8's garbage collector. Every token, every mutation result, every secret match was a new heap allocation. With 1000 tokens, that is thousands of allocations per request.
The single-threaded problem was fundamental. Node.js is single-threaded by design. Parallelizing the scan across multiple cores would have required worker threads and the communication overhead that entails.
We needed to go lower.
The Decision: Rust with Rayon
The choice of Rust was driven by three requirements:
- -Zero-copy string handling where possible
- -True parallelism across cores (for batch file scanning)
- -Compile-time regex compilation (via the
regexcrate's lazy static pattern)
We also needed to keep the Node.js interface: Pretense's CLI, proxy server, and most of the business logic was staying in TypeScript. The scanner needed to be callable from Node.js with minimal overhead.
That meant NAPI bindings.
Architecture: NAPI Bindings
NAPI (Node.js API) is the stable ABI for native Node.js modules. A Rust library compiled with napi-rs produces a .node file that TypeScript can import as a native module.
The key advantage over a child process or gRPC approach: NAPI calls are in-process. The call skips process-boundary serialization, inter-process communication, and socket roundtrips entirely. The Rust code runs in the same process as the Node.js host.
The NAPI boundary has a cost: JSON serialization/deserialization for the input and output. We measured this at approximately 0.15ms for a 1000-token file. It is the dominant cost at small sizes, which is why the benchmark gap narrows at very small inputs.
The Rust Scanner: Key Implementation Details
Three choices carry the speed. Regexes compile once at startup instead of on every call. A batch of files scans in parallel across every CPU core. And the stand-in for each identifier is derived deterministically, so the same name maps to the same stand-in for the whole project.
For a 50-file context window, the parallel scan spreads the files across all available cores.
Benchmark Methodology and Results
We benchmarked on:
- -Input: 1000 code tokens drawn from real TypeScript production code
- -Hardware: Apple M3 Pro, 18GB RAM
- -Runs: 10,000 iterations per measurement, report mean + p99
- -Cold start excluded: the comparison is warm-path performance
| Implementation | Mean | p99 | Throughput |
|---|---|---|---|
| TypeScript v0.1 (with regex bug) | 50.2ms | 89ms | 20 req/s |
| TypeScript v0.1 (regex cached) | 28.1ms | 41ms | 36 req/s |
| Rust via NAPI (single file) | 1.82ms | 2.9ms | 549 req/s |
| Rust via NAPI (batch, 50 files) | 8.4ms total | 12ms | 5,952 files/s |
The 27x headline number is TypeScript v0.1 (with regex bug) vs. Rust single-file: 50.2ms to 1.82ms.
The more meaningful comparison is TypeScript v0.1 (regex cached, representing an optimized TypeScript implementation) vs. Rust: 28.1ms to 1.82ms, which is 15.4x.
Both represent meaningful improvements. The batch performance is where the Rust advantage compounds: scanning 50 files in 8.4ms is out of reach for TypeScript on a single thread.
The NAPI Serialization Cost
At small input sizes, the NAPI boundary dominates. JSON serialization for a 10-token file costs approximately 0.12ms, which is 6.5% of the 1.82ms total for a 1000-token file. At 50 tokens, it is a larger fraction.
This means the 27x figure applies to typical inputs, and the speedup is smaller for trivially small inputs. For inputs under 100 tokens, the gap is closer to 8-10x. The improvement is most dramatic for the medium-to-large code blocks that are typical in real Claude Code sessions.
Lessons Learned
1. Profile before rewriting. We found the regex compilation bug in TypeScript that brought 50ms to 28ms before writing a line of Rust. If that had been the only problem, the TypeScript fix alone would have been enough.
2. NAPI serialization is the ceiling at small sizes. If we were doing this again, we would investigate zero-copy NAPI buffers for the input string in place of JSON serialization. This is a 0.12ms improvement still on our list.
3. Rayon is trivially easy for embarrassingly parallel workloads. Replacing .iter() with .par_iter() and letting Rayon handle thread pooling is one of the best power-to-effort ratios in systems programming.
4. The TypeScript caller stays the same. Because we designed the NAPI interface to accept and return JSON strings, the TypeScript caller imports a function that looks like any other function. The rewrite was invisible to every other part of the codebase.
5. The build pipeline is the hard part. cargo build --release targeting multiple architectures (x86_64, arm64, linux-musl for Docker) required more CI configuration than the Rust code itself. Set up the cross-compilation pipeline early.
The Current State
The Rust scanner ships in Pretense v0.2.0 and later. It is the default scanner for all installations. The TypeScript fallback remains in the codebase for debugging purposes and stays inactive in production paths.
The CLI that ships the scanner is MIT-licensed.
How I run Pretense on my own code
I build Pretense with Claude Code, and my sessions run through it. Here is the setup I give a new engineer, in order.
- -Run the agent through the proxy. Type
pretense run claudewhere you would typeclaude. It starts a local proxy on port 9339, points Claude Code at it and stops the proxy when the session ends. Detected secrets and PII leave the laptop as stand-in tokens. - -Put hooks on every repo.
pretense installadds a pre-commit and a pre-push hook. A key that never lands in git is a key an agent never reads. - -Scan before an agent reads the repo.
pretense scan path .lists what it finds. In CI,pretense scan ciexits 0 when clean, 2 on findings and 3 when it examined nothing. Treat 3 as a failure. - -Preview the swap.
pretense review .shows what would be mutated.pretense mutate <file> --previewprints one file mutated and leaves the file on disk as it was. - -Round-trip a file you need to share.
pretense mutate <file> --map <map>swaps the secrets in place.pretense reverse <file> --map <map>turns it back into the original, byte for byte, from the encrypted map on your machine. We run that round trip on real files before every CLI release and compare sha256 hashes.
One thing I learned the hard way: inside a session the agent sees stand-ins, so a file the agent writes can hold a stand-in where you expected a real value. Grep what the agent wrote before you ship it.
What Pretense will not do is put a secret back into the model's answer. Code identifiers come back byte-exact. Secrets and PII stay as stand-ins in the response. That is deliberate: a prompt-injected model can echo a token into a link, and reversing it there would hand over the real key.
What this means if you lead an engineering team
The outcome: your team keeps asking AI assistants about real systems, configs and connection strings, and detected secrets leave each laptop as stand-ins.
- -No new workflow. Engineers type
pretense run claudein place ofclaude. Other AI CLIs run the same way, for examplepretense run gemini. - -Guard the repo as well as the prompt.
pretense installadds pre-commit and pre-push hooks.pretense scan ciin the pipeline fails on findings (exit 2) and on an empty scan (exit 3). - -Answers stay usable. Identifiers inside fenced code blocks go out as stand-ins and come back byte-exact, so the code the model writes still uses your names.
- -Measure before you mandate. Run it on your own repo for a week.
pretense auditshows what it swapped and when.
Try it on your own repo: create a free account, then run npm i -g @pretense/cli and pretense run claude. If you need a security review first, talk to me.