Skip to content

Performance and footprint

Japanese analysis makes a visible difference: loading the seed took Go embedded from 2.02 ms to 335 ms. SDK launch adds another boundary—child-process creation, and for Java, extracting the bundled executable from the classpath. These are local measurements, not guarantees.

This page was re-measured on 2026-09-16 after a round of OpenSearch 3.8 REST-API compatibility fixes and two targeted performance changes (in-memory segment merging for single-document writes, typed top-K sort execution). Re-measuring surfaced two more regressions that turned out to be accidental rather than an inherent cost of the compatibility work: Cluster.Clone() and plain queries had gotten slower because the HTTP route table was being rebuilt on every New()/Clone() call, and because every query was filtered through an extra clause meant for nested documents even on mappings that have none. Both were profiled and fixed in this same pass (see below); Clone() in particular now runs faster than any previously published number. One cost remains open: bool-query _search latency, which is an inherent trade-off of a Lucene-faithful score-combination rewrite, not a bug—also covered below.

Two changes landed since the previous measurement:

  • In-memory segment merging (decision record): bleve’s scorch index never merges segments unless it owns a directory, so every single-document write used to keep a segment of its own—and each segment reserves buffer space for around 100 documents. Indices now track which documents each segment holds and re-index small segments together instead.
  • Typed, top-K-bounded sort (decision record): sorting resolved a spec and extracted keys through []any on every hit and ran a full stable sort even when only a page of 10 was requested. Sort keys are now typed and cached at write time, and a bounded heap selects just the requested page.

Measured by building the commit right before this work (0631107) and current HEAD on the same machine and running the identical benchmark back to back; BenchmarkSingleDocWrites and BenchmarkSort100k did not exist before this work; their pre-fix numbers below reuse the pre-fix Cluster API under the same workload. Medians of 3–5 runs.

Benchmark Before After
5,000 single-document writes: heap retained 2,113 MiB 26.1 MiB
5,000 single-document writes: term query 2.44 ms 43.7 µs
5,000 single-document writes: time per write 1.83 ms 480 µs
1,000 single-document writes: heap retained 421.5 MiB 12.0 MiB
1,000 single-document writes: term query 301 µs 44.8 µs
sort 100k docs, page 10, by double 228.8 ms 73.5 ms
sort 100k docs, page 10, by date 268.3 ms 55.4 ms
sort 100k docs, page 10, by keyword 260.0 ms 70.9 ms
sort 100k docs, page 10, by two keys 323.4 ms 79.1 ms

At 1,000 single-document writes, per-write latency is roughly unchanged (consistently ~450–500 µs either way); the unmerged-segment penalty mainly shows up in retained heap and query cost at that scale. It grows with writes: at 5,000 writes the old code was also paying escalating GC cost on its 2 GiB heap, which the new code avoids by keeping segments bounded at documents/1000 + 27.

Two regressions found and fixed, one left open

Section titled “Two regressions found and fixed, one left open”

The same session also merged a broad OpenSearch 3.8 response-fidelity pass (commit 71962c6): comparing osmem with real OpenSearch 3.8.0 and 2.19.1 over 477 scenarios, differing requests dropped from 3,263 to 308 of 6,139. Re-measuring this page found that Cluster.Clone() and plain queries had gotten measurably slower since the last measurement. Bisecting commit by commit on this machine (each built and benchmarked back to back) traced it to two specific, unrelated causes—neither one actually required by the compatibility work, both fixed here:

  • The route table was rebuilt on every New()/Clone() call. newHTTPHandler called buildRoutes() (over 150 routes, each a closure) and re-built the routing trie from scratch every time, and had done so since the very first commit; 71962c6 made each route more expensive to construct (added parameter-validation metadata) without anyone noticing the table wasn’t cached. No handler closure or trie node is specific to one Cluster—every handler takes the *httpHandler as an argument and reaches the cluster through it—so the table and trie are now built once per process and shared. Decision record.
  • Every query was filtered for root documents, even with no nested field in the mapping. Nested-as-child-documents (2026-09-14) added a second query clause to keep nested child documents out of every hit list, unconditionally. A mapping with no nested field can never index a nested child, so the clause was always a no-op there—but it still doubled bleve’s DocumentMatchPool pre-allocation cost by pushing the request past bleve’s normal 1,000-document pre-allocation cap. The clause is now skipped when the mapping has no nested field. Decision record.

Both were confirmed with pprof (-cpuprofile/-memprofile on the affected benchmarks) before fixing, and go test -race ./... passes after. The columns below are the last published baseline, this session’s regressed-but-not-yet-fixed measurement, and the fixed current state:

Benchmark Last published Regressed Fixed (now)
Cluster.Clone() alone (no query) 14.4 µs 60.1 µs 0.8 µs
Cluster.Clone() + a _count query 315 µs ~407 µs ~175 µs
plain term query, 10k docs 37.7 µs ~72 µs ~45 µs
_search: bool query + sort + date_histogram, 10k docs 1.78 ms 4.5 ms 4.5 ms (see below)

Clone() alone is now faster than it has ever measured on this page—it takes no HTTP request and does no query-DSL parsing, so removing the per-call route rebuild left it as close to a bare in-process copy-on-write fork as this benchmark gets. The term query lands close to its original baseline; the small remaining gap is untouched by either fix and wasn’t investigated further this round.

_search with a bool query is not fixed, and isn’t a bug. Its query goes through a different mechanism, also added by 71962c6 (internal/engine/query_exec.go): bleve’s own compound searchers combine scores by Lucene query norms and coordination factors that don’t match real Lucene, so osmem’s bool/dis_max/boosting/function_score queries now compute Lucene-correct scores themselves. Doing that requires draining every match of every clause into memory before combining them—it cannot stream or early-terminate the way a single-clause query can. That’s an inherent cost of the correctness fix, not an accident, and profiling confirms it: search.NewDocumentMatchPool, called with the clause’s full match count, is the largest allocator in a BenchmarkSearch10k memory profile. A fix would mean an early-terminating evaluator that still combines scores the Lucene way—a larger redesign, not attempted here.

Re-measured on 2026-09-16 on the same Apple M3 / Go 1.27.0 / Python 3.14.7 / Node.js 26.8.1 / Java 25.0.2 / Docker 29.4.0 (OrbStack) machine as the previous pass, this time sharing the machine with other concurrent work, so treat single means as noisier than before. Startup itself is barely touched by this session’s engine changes: New()/LoadSeed() calls New() once, so the route-table fix in Two regressions found and fixed, one left open saves on the order of 60 µs here, well under this table’s measurement noise (see Write and sort performance and that section for what actually moved this round). The container rows (Docker/Testcontainers/Devbox) and the download-size section weren’t re-run since they measure infrastructure this session didn’t touch, and keep their 2026-09-13/14 figures.

API path Japanese Mean startup Range Timing boundary
Go embedded off 2.02 ms 1.52–4.82 ms (n=10) osmem.New() + LoadSeed(); the already-running Go test process and executable launch are excluded
Go embedded on 335.1 ms 313.3–404.1 ms (n=10) Same; imports and enables osmem/ja
Python server SDK off 126.2 ms 18.2–549.9 ms (n=5) OsmemServer.start() launches the child and waits for seeded-ready; Python runner already running
Python server SDK on 339.6 ms 327.0–358.3 ms (n=5) Same, Japanese analyzer enabled
Java server SDK off 462.0 ms 371.0–816.7 ms (n=5) OsmemServer.start() includes extracting the bundled executable from the classpath, then child startup and seed load
Java server SDK on 769.9 ms 725.7–837.0 ms (n=5) Same, Japanese analyzer enabled
Node.js server SDK off 22.7 ms 10.9–68.3 ms (n=5) OsmemServer.start() launches the child and waits for seeded-ready; Node runner already running
Node.js server SDK on 326.5 ms 320.3–346.2 ms (n=5) Same, Japanese analyzer enabled
Docker OpenSearch — 6.07 s 5.99–6.31 s (n=5) Warm opensearchproject/opensearch:2.19.0, linux/arm64; until successful PUT /benchmark; not re-run, see above
Testcontainers Go + OpenSearch — 6.48 s 5.77–8.22 s (n=5) Testcontainers-Go 0.44.0, fresh container each run, cached image; 1 GiB tmpfs at /usr/share/opensearch/data; first run starts Ryuk; not re-run, see above
Devbox-managed OpenSearch — 7.94 s 6.85–10.63 s (n=5) Devbox 0.17.5 services up -b; process-compose runs docker run; cached image; not re-run, see above

For Python, Java, and Node.js, the language runtime is already alive before timing begins, matching a test runner calling the SDK; Java’s classpath-extraction step and Python/Node’s already-present executable are unchanged. The Go embedded measurement has no HTTP listener and excludes starting the Go test process; each of its n=10 trials is a fresh single-shot process, since Japanese analyzer initialization turned out to be a one-time cost per process (see below), not per call. Container rows have a different ready condition (PUT /benchmark) and are context, not a controlled comparison with the seeded osmem fixture. Write latency is omitted: suites are expected to reuse prebuilt indexes.

New observation: Japanese analyzer initialization is a one-time cost per process. Calling osmem.New() repeatedly after importing osmem/ja in the same process pays the kuromoji/kagome dictionary load only on the first call; every subsequent call was 0.75–1.0 ms in a quick check, the same range as Japanese-disabled. A Go test suite that imports osmem/ja and builds one base cluster in TestMain, as this project’s own README does, pays the ~320 ms cost once per test binary run, not once per test.

Clone an already-built Japanese-enabled seed, then measure only clone creation. The test case can mutate its fork without affecting the shared base; no write or cleanup time is included.

API path Mean clone creation Samples
Go embedded Cluster.Clone() 0.8 µs 5 batches × 5,000 clones; seed setup outside timer
Python server.clone() 215 µs 300 clones from one running seeded server
Java server.clone() 507 µs 300 clones from one running seeded server
Node.js server.clone() 1.77 ms 300 clones from one running seeded server

Measured after the fixes in Two regressions found and fixed, one left open; the Go embedded row is now the fastest ever measured on this page (was 14.4 µs previously, 60.1 µs mid-session before the fix). The language SDK rows go through the same in-process Clone(), diluted by the local HTTP request and JSON decoding, so they improve less in relative terms (5–30% off the mid-session regression); Python and Java also land below their previous published measurement (240 µs, 583 µs), while Node.js improves but stays above its previous 1.63 ms. Closing the clone and any subsequent index/document mutation are outside the timer.

Path Memory at ready Query Conditions
osmem server, Japanese analyzer exercised 161.8 MiB RSS — 5 processes; same seed; sample taken after a Japanese match query
Go in-process API not measured filtered search + sort + date histogram: 4.5 ms; exact term: ~45 µs; read-only clone + count: ~175 µs 10,000-document fixture; `go test -run ‘^$’ -bench ’Benchmark(CloneReadOnly
osmem HTTP query, Japanese disabled not measured median 0.5 ms, p95 0.6 ms, p99 1.1 ms 500 sequential match_all, size 10 requests against the 3-document seed; dominated by loopback HTTP and curl process overhead at this document count, not engine time
Docker OpenSearch 942.2 MiB container RSS median 4.45 ms, p95 9.76 ms, p99 19.02 ms docker stats after test index creation; query sample from a separate run; not re-run, see Startup by API
Testcontainers Go + OpenSearch 954.4 MiB container + 1.3 MiB runner RSS increase = 955.7 MiB — Go runner: 19.0 MiB before startup, 20.3 MiB ready; 1 GiB data tmpfs; excludes Docker daemon and Ryuk; not re-run
Devbox-managed OpenSearch 947.6 MiB container + 61.0 MiB process-compose/Docker CLI = 1,008.6 MiB — Excludes Docker daemon; not re-run

The query rows do not use the same index or query plan: the Go benchmark searches 10,000 documents, the osmem HTTP check uses a small seed, and OpenSearch searches an empty index. They show local path costs, not a controlled engine shootout. Docker uses one node, a 512 MiB heap, and disabled security demo setup. Hardware, heap, architecture, storage driver, image cache, and startup policy can change these results substantially.

Testcontainers is a test-side wrapper around a container runtime, not another search engine. This comparison uses the Go implementation (testcontainers-go 0.44.0) to avoid attributing a large, noisy JVM baseline to the container. Five fresh-container trials averaged 6.476 seconds to the index-ready request (5.774–8.223 seconds). OpenSearch used a 512 MiB heap with security disabled, and /usr/share/opensearch/data was mounted as a 1 GiB tmpfs with UID/GID 1000. The OpenSearch image was cached; the first trial also started Testcontainers’ Ryuk helper. At readiness, docker stats reported 954.4 MiB of container memory. The Go runner averaged 19.0 MiB RSS before startup and 20.3 MiB when ready, so the 1.3 MiB increase gives a combined figure of 955.7 MiB. The Docker daemon and Ryuk are excluded. These tmpfs-backed figures use a different storage condition from the Docker and Devbox rows. A class-scoped Testcontainers container can amortize one startup across that class’s tests. None of this changed since the previous measurement (2026-09-14); it is not re-run here.

Reproduce the Testcontainers measurement from the repository root:

Terminal window
cd bench/testcontainers
go run .

The harness times container creation through a successful PUT /benchmark, then verifies the tmpfs mount with docker inspect. It prints five fresh-container trials by default; pass a positive number to change the trial count.

Devbox can manage a service through process-compose; the official services guide describes devbox services up and background mode. Here, devbox services up -b starts the same cached OpenSearch image through a process-compose docker run service. Five fresh starts averaged 7.94 seconds to a successful PUT /benchmark. Once ready, the container averaged 947.6 MiB RSS and process-compose plus its persistent Docker CLI averaged 61.0 MiB, for 1,008.6 MiB total. The Docker daemon is excluded. The warm no-op devbox run measurement (156 ms) and first Maven/JDK closure download (194.1 MiB, 351.7 MiB unpacked) are separate development-toolchain costs—not server startup or server image size. None of this changed since the previous measurement; it is not re-run here.

Linked application binary and container download size

Section titled “Linked application binary and container download size”

For library size, this reports the incremental size in the final linked Go executable, not source-code or package/archive size. Three minimal Go programs were built on this machine on 2026-09-16 with Go 1.27.0, -trimpath, and -ldflags=-buildid=: an empty main, one that constructs and closes an osmem cluster, and one that also imports the Japanese analyzer. The optional analyzer measurement is cumulative from the empty baseline; its incremental addition over osmem alone is also shown.

Go executable Size
Minimal main baseline 1,815,298 bytes (1.73 MiB)
osmem-linked executable 32,771,522 bytes (31.26 MiB); +30,956,224 bytes (+29.52 MiB)
osmem + Japanese analyzer 45,622,018 bytes (43.51 MiB); +43,806,720 bytes (+41.78 MiB) vs baseline, of which the analyzer adds 12,850,496 bytes (12.25 MiB)

The osmem-linked increment grew by about 6.8 MB since the previous measurement (was 23.85 MB / 22.75 MiB): internal/engine/aggs_dates.go now blank-imports time/tzdata to embed Go’s IANA timezone database unconditionally—the fix for a windows-latest CI hang traced to platform-dependent zone data—plus the OpenSearch 3.8 compatibility and segment-merge/sort work added a substantial amount of engine code in the same session. The Japanese analyzer’s own incremental cost is essentially unchanged (12.25 MiB vs 12.26 MiB previously): it depends on the kagome/IPADIC dictionary, which this round did not touch.

These are toolchain- and program-dependent linker results, not a universal package-size guarantee. Node.js and Python use a separate osmem child executable, and Java uses JVM artifacts, so their integration does not have a comparable statically linked application-binary delta.

The homepage puts three sizes on one decimal-MB scale: the linked Go app’s 43.8 MB increment (41.78 MiB), the first Devbox Maven/JDK environment download (203.5 MB, or 194.1 MiB), and the OpenSearch image’s 739.3 MB compressed linux/arm64 size listed by Docker Hub. These are deliberately distinct scopes: a linked executable delta, a one-time toolchain download, and a complete compressed server image. Docker’s local Size is expanded storage and is not used as a download-size proxy; actual transfer can be lower when layers are already cached.

Testcontainers uses that same OpenSearch image; it does not link a second search binary into the test application. Devbox’s 194.1 MiB figure is the downloaded Maven/JDK environment closure, a separate setup cost rather than an application binary size.

Run from the repository root:

Terminal window
go test -run '^$' -bench 'Benchmark(CloneReadOnly|Search10k|TermQuery10k|SingleDocWrites|Sort100k)$' -benchmem -count=3

The benchmarks build their document fixture (10,000 documents for CloneReadOnly/Search10k/TermQuery10k, 100,000 for Sort100k) before timing the query cases; SingleDocWrites indexes its own 1,000 or 5,000 documents one at a time as part of what it measures. For service startup and client-transport measurements, record whether compilation, binary extraction, image pulling, and seed loading are inside or outside the timer. To compare a change against a baseline the way this page’s before/after tables do, build both commits with git worktree add and run the same -bench command back to back on the same machine—single-point measurements on a shared or otherwise busy machine vary enough (see Startup by API) that a same-session, same-machine comparison is more trustworthy than diffing two dated table rows. The same technique, repeated across several intermediate commits, is how the two regressions above were traced to specific commits rather than “somewhere in the last three days.”