Skip to main content

Deploy on a serverless runtime

Outcome. You know which of the two deploy shapes fits your platform's disk and memory limits, what a cold start costs in each, and how to mount the data root read-only.

Mailwoman is disk-resident. There is no service to call: the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' is a file, the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. is a SQLite file, and both are opened from the local filesystem. That makes the deploy question a sizing question, and the two shapes have very different answers.

Prerequisites

  • A runtime on linux/x64 with glibc. onnxruntime-node ships glibc prebuilds; musl (Alpine) has none, which rules out an Alpine base without building ONNX RuntimeONNX (Open Neural Network Exchange). An open format for machine learning models that enables interoperability between training frameworks and inference runtimes. Mailwoman ships its trained model as an ONNX file so it can run in Node.js and the browser via onnxruntime. yourself.
  • Node.js ≥ 24.18.0.
  • A read path to a data root if you are geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available.. Object storage is not enough on its own — see Limits.

Every number below was measured on one host: an AMD Ryzen 9 8945HS, 16 logical cores, 29 GB RAM, Node.js 26.2.0, packages installed from npm at 8.6.0. The machine was shared, so read the absolutes as one host's figures and the shape as what transfers.

1. Pick the shape

Two deploys, and the difference is whether a gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. is on the instance.

Parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates.-onlyParseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. and geocode
npm packagesmailwoman, @mailwoman/neural, @mailwoman/neural-weights-en-usthose plus @mailwoman/resolver, @mailwoman/resolver-wof-sqlite
node_modules as installed746 MB746 MB
node_modules after trimming303 MB303 MB
Data rootnone1.65 GB and up
Answerscomponents, confidence, a match keycanonical keyA deterministic, normalized string representation of an address produced by @mailwoman/formatter. Lowercase, abbreviation-expanded, punctuation-stripped — so '123 Main St' and '123 MAIN STREET' produce the same key. Used for blocking in the matcher.components plus a coordinate

node_modules is the same size for both, which is the first surprise. @mailwoman/resolver-wof-sqlite adds nothing measurable because the weightparameterA single learned number inside a model — one weight or bias. Mailwoman's encoder has roughly 30 million of them; training is the search for good values. is somewhere else entirely.

2. Trim onnxruntime-node, or do not fit

onnxruntime-node is 500 MB of the 746 MB, and most of it is for hardware you do not have:

du -sh node_modules/onnxruntime-node/bin/napi-v6/*
75M node_modules/onnxruntime-node/bin/napi-v6/darwin
297M node_modules/onnxruntime-node/bin/napi-v6/linux
128M node_modules/onnxruntime-node/bin/napi-v6/win32

Inside the linux directory, libonnxruntime_providers_cuda.so alone is 344 MB. CPU inferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece. never loads it. Two rms take the tree to a third of its size:

rm -rf node_modules/onnxruntime-node/bin/napi-v6/{win32,darwin}
rm -f node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_{cuda,tensorrt}.so
303M node_modules

The trimmed tree still parsesaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. — the same cold-start script from step 3, run against it:

import 84 ms | load 691 ms | first parse 179 ms | total 953 ms | rss 373 MB

What survives is libonnxruntime.so.1 (37 MB) and onnxruntime_binding.node (389 KB), which is everything CPU inferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece. uses. 303 MB still exceeds AWS Lambda's 250 MB unzipped ceiling for a zip deploy, so a container image (10 GB) or a mounted layerlayerOne transformer block — attention plus a feed-forward network, with normalization and residual connections — applied to every position. Stacking layers lets the model build up richer representations; Mailwoman's encoder has 6. is the route on that platform. On platforms whose limit is a container image size, 303 MB plus 71 MB of weightsparameterA single learned number inside a model — one weight or bias. Mailwoman's encoder has roughly 30 million of them; training is the search for good values. is comfortable.

3. Measure the cold start

The cost that matters on a serverless runtime is the first request on a new instance. Time the three phasesphaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). separately, because only one of them is worth optimizing:

const t0 = performance.now()
const { NeuralAddressClassifier } = await import("@mailwoman/neural")
const imported = performance.now()
const classifier = await NeuralAddressClassifier.loadFromWeights({ locale: "en-US" })
const loaded = performance.now()
await classifier.parse("1600 Pennsylvania Ave NW, Washington, DC 20500")
const parsed = performance.now()

console.log(
`import ${(imported - t0).toFixed(0)} ms | load ${(loaded - imported).toFixed(0)} ms | ` +
`first parse ${(parsed - loaded).toFixed(0)} ms | total ${(parsed - t0).toFixed(0)} ms | ` +
`rss ${(process.memoryUsage().rss / 1e6).toFixed(0)} MB`
)

Five consecutive runs, parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates.-only:

import 101 ms | load 775 ms | first parse 231 ms | total 1107 ms | rss 374 MB
import 97 ms | load 762 ms | first parse 177 ms | total 1037 ms | rss 373 MB
import 97 ms | load 881 ms | first parse 189 ms | total 1167 ms | rss 369 MB
import 102 ms | load 789 ms | first parse 193 ms | total 1085 ms | rss 407 MB
import 99 ms | load 844 ms | first parse 195 ms | total 1138 ms | rss 376 MB

Adding the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. and geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. an address instead of parsingaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. it:

import 191 ms | model 791 ms | open db 1 ms | first geocode 223 ms | total 1207 ms | rss 425 MB
import 215 ms | model 785 ms | open db 1 ms | first geocode 223 ms | total 1224 ms | rss 425 MB
import 194 ms | model 732 ms | open db 1 ms | first geocode 216 ms | total 1144 ms | rss 427 MB
import 198 ms | model 673 ms | open db 1 ms | first geocode 219 ms | total 1091 ms | rss 426 MB
import 192 ms | model 690 ms | open db 1 ms | first geocode 226 ms | total 1109 ms | rss 427 MB

Three things to take from that.

Opening the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. costs 1 ms. A 1.65 GB SQLite file is memory-mapped, not read, so the file's size does not appear in the cold start at all. GeocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. adds about 100 ms of cold start over parsingaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates., and all of it is the extra module graph, not the database.

Loading the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' is 70% of the cold start and cannot be moved. It is one ONNXONNX (Open Neural Network Exchange). An open format for machine learning models that enables interoperability between training frameworks and inference runtimes. Mailwoman ships its trained model as an ONNX file so it can run in Node.js and the browser via onnxruntime. session build over a 39 MB graph. Warm requests do not pay it, so the lever is instance lifetime, not code.

Resident set lands near 375 MB parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates.-only and 425 MB with a resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon.. Size the instance above that rather than at it — the parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates.-only figure moved between 369 MB and 407 MB across five runs on an idle box.

4. Build the classifier once, outside the handler

Everything above is per-instance, not per-request, which only holds if the handler does not rebuild it. Build at module scope so a warm invocation reuses the session and the open database:

import { NeuralAddressClassifier } from "@mailwoman/neural"
import { createWOFResolver } from "@mailwoman/resolver"
import { WOFCandidateTableLookup } from "@mailwoman/resolver-wof-sqlite"
import { geocodeAddress } from "mailwoman/geocode-core"
import { resolveCandidateDBPath } from "mailwoman/resolver-backend"

// Module scope: paid once per instance, reused by every warm invocation.
const classifier = await NeuralAddressClassifier.loadFromWeights({ locale: "en-US" })
const lookup = new WOFCandidateTableLookup({ databasePath: resolveCandidateDBPath() })
const resolver = createWOFResolver(lookup)

export async function handler(event) {
const result = await geocodeAddress(event.address, { classifier, resolver, defaultCountry: "US" })

return { statusCode: 200, body: JSON.stringify(result) }
}

Do not close the lookup in the handler. The instance owns it, and closing it turns every later warm request into an error.

5. Mount the data root read-only

Every runtime lookup opens its database with readOnly: true, the FTS admin backend included (resolver-wof-sqlite/lookup.ts). A read-only mount is the correct shape for both backends, and the only writable open is an explicit FTS index build, which is not a serve path.

Set $MAILWOMAN_DATA_ROOT to the mount point. If the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. sits at wof/candidate.db underneath it, that is the whole configuration — the candidate database is resolved from the convention path when nothing overrides it:

MAILWOMAN_DATA_ROOT=/mnt/mailwoman-data

Set $MAILWOMAN_CANDIDATE_DB as well only when the file lives somewhere that convention does not reach:

MAILWOMAN_CANDIDATE_DB=/mnt/gazetteer/candidate-2026-07-07a.db

Both are read on each call rather than cached at import, so setting them after the module graph loads still works. That is why the snippet in step 4 calls resolveCandidateDBPath() rather than reading either variable itself: the helper applies the full flag-then-variable-then-convention search, it is what the CLI and the drop-in servers use, and it returns nothing rather than a path when the file is absent — so a cold instance with a broken mount fails at the resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. you can see instead of inside SQLite.

Two mount details that bite. If wof/candidate.db is a symlink on the host, point $MAILWOMAN_CANDIDATE_DB at the real file — an absolute-path symlink dangles inside a container. Discovery uses existsSync, which follows the link, so a dangling one reads as absent: the convention path finds nothing, the instance silently falls back to the FTS backend, and with no WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. distribution mounted your geocode route answers 503 instead of failing at boot. And the per-state shardsshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. are WAL-mode: their -wal and -shm siblings have to be in the same mount, or that address falls back to a coarser tier rather than failing.

Verify

On a deployed instance, confirm the cold path completes and report which artifacts it found:

mailwoman doctor --json
{
"checks": [
{
"id": "weights",
"label": "Model weights (en-us)",
"core": true,
"status": "ok",
"detail": "package:@mailwoman/neural-weights-en-us · model.onnx 39.4 MB, tokenizer.model 1.6 MB"
},
{
"id": "onnxruntime",
"label": "ONNX runtime",
"core": true,
"status": "ok",
"detail": "onnxruntime-node loadable"
}
]
}

The onnxruntime check is the one that catches a trim that went too far.

Limits

  • The data root has to be a filesystem, not an object store. SQLite needs pread on a local file descriptor. S3 behind a userspace mount works and is slow; a network filesystem with real random reads works. There is no HTTP-range gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. client in the Node runtime today.
  • 250 MB unzipped is not reachable. The trimmed tree is 303 MB before weightsparameterA single learned number inside a model — one weight or bias. Mailwoman's encoder has roughly 30 million of them; training is the search for good values.. On AWS Lambda that means container images or a layerlayerOne transformer block — attention plus a feed-forward network, with normalization and residual connections — applied to every position. Stacking layers lets the model build up richer representations; Mailwoman's encoder has 6., not a zip.
  • One instance is one CPU-bound worker. InferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece. blocks the JavaScript threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases., so a single instance serves one geocode at a time no matter how deep the request queue gets. Scale with instances. Geocode a large file at volume measures this.
  • There is no GPU path in Node. WebGPU exists only in the browser runtime (@mailwoman/neural/web-onnx-runner); removing the CUDA provider above costs nothing because nothing loads it.
  • Cold start is about 1.1 s and the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' load is 70% of it. If your platform charges for initialization time, budget for it rather than trying to shrink it.