JMAP: A Modern JSON-over-HTTP Alternative to IMAP (and How to Adopt It)
IMAP isn't slow because your server is weak — it's slow by design, forcing eight sequential round trips just to paint an inbox. JMAP collapses that staircase into one batched JSON POST. Here's why the real win is server-side, and how to run JMAP alongside IMAP without touching a single user's client.
EvilMail TeamJuly 31, 20269 min read
Count the round trips it takes to paint an inbox over IMAP. A client opens a TCP connection, then walks a strictly sequential staircase: CAPABILITY, LOGIN, LIST "" "*", SELECT INBOX, UID SEARCH ALL, UID FETCH 1:30 (FLAGS ENVELOPE), and finally UID FETCH n (BODY[]) to open the one message the user tapped. That's eight round trips that must happen in order, because each one depends on the answer to the last. On a mobile radio with a 90 ms round-trip time, you've burned roughly 700 ms of pure latency before a single glyph of actual mail hits the screen — and that assumes TLS is already up and nothing retransmits.
IMAP isn't slow because your server is underpowered. It's slow by design: a stateful, one-folder-at-a-time protocol that makes the client pay a full RTT per step. JMAP fixes this on the wire by collapsing the staircase into a single batched JSON POST. The part most write-ups skip: you adopt JMAP server-side
, not by telling users to switch clients. It's a mailstore and gateway decision, not a client one.
Why IMAP is slow where it actually matters
The problem isn't bandwidth — envelopes are tiny. It's that IMAP is chatty and latency-bound, and latency is the one thing you can't buy your way out of.
It's connection-bound and stateful. One TCP connection has exactly one SELECTed mailbox at a time. Want to check three folders? Re-SELECT sequentially, or open three connections.
IDLE only watches the selected mailbox. Push for Inbox, Archive, and a Sieve-filtered folder means three IDLEing connections held open — or you fall back to polling and eat the wake-ups.
Sync was bolted on years later.CONDSTORE and QRESYNC (RFC 7162) give you MODSEQ/HIGHESTMODSEQ so a client can ask "what changed since state X?" — but support is uneven across servers and clients, and the model is still strictly per-mailbox.
Every round trip can wake the radio. On mobile this is the real tax. Eight sequential requests don't just add 8× RTT; they repeatedly pull the modem out of a low-power state.
You can tune Dovecot all day and the staircase keeps the same shape. The fix has to be a different protocol.
What JMAP actually is: batched method calls over HTTP
JMAP is defined by RFC 8620 (JMAP Core, 2019) and RFC 8621 (JMAP Mail), with RFC 8887 adding a WebSocket transport. It's stateless HTTPS with bearer-token auth — no SASL handshake, no long-lived TCP connection required — and it rides your existing HTTP/2 vhost.
Discovery starts at a well-known URL. The client makes one authenticated GET:
That returns the Session object, the map of everything the client needs:
capabilities — the URNs the server supports, e.g. urn:ietf:params:jmap:core, urn:ietf:params:jmap:mail, urn:ietf:params:jmap:submission, urn:ietf:params:jmap:vacationresponse.
accounts / primaryAccounts — which account IDs this token can touch.
apiUrl, downloadUrl, uploadUrl, eventSourceUrl — where to POST method calls, and where to move binary out of band.
The core capability also advertises hard limits: maxCallsInRequest, maxObjectsInGet, maxObjectsInSet, maxSizeRequest, maxConcurrentRequests. You batch within those.
Everything after discovery is a POST to apiUrl carrying a Request: {"using":[...], "methodCalls":[[name, args, callId], ...]}. Multiple methods per request, executed in order. The feature that earns its keep is back-references: a later call can reference an earlier call's result via a {"resultOf", "name", "path"} pointer, so the output of one method feeds the input of the next *inside the same round trip*. The typed objects you work with are Mailbox, Email, Thread, EmailSubmission, Identity, VacationResponse, and SearchSnippet. Attachments never travel inline — they're referenced by blobId and moved through the upload/download URLs.
A real inbox render in one round trip
Here's the eight-step IMAP staircase rewritten as a single JMAP POST. Email/query finds the message IDs; Email/get pulls just the properties the list view needs — and the #ids back-reference wires them together so the server runs both server-side before it responds.
One HTTP request, one RTT. Note the state string on the Email/get result — that opaque token is your anchor for sync, which is the next problem JMAP solves cleanly.
Sync and push without the polling tax
Every typed object set in JMAP carries a state string. To catch up after being offline, the client calls Email/changes with sinceState and gets back exactly what moved:
The response is { "created": [], "updated": [], "destroyed": [], "newState": "…" }. Email/queryChanges does the same for a live query result, so an open inbox view stays correct without re-running the whole query. One uniform mechanism replaces the QRESYNC machinery — and it works identically across every object type, not just per-mailbox.
Push comes in three flavors, and one channel covers all mailboxes at once — no IDLE-per-folder:
EventSource (SSE) at eventSourceUrl — dead simple for web clients; the browser reconnects for you.
WebSocket (RFC 8887) — bidirectional and low-latency, good when the client also sends a lot.
PushSubscription — the server POSTs StateChange objects to an external URL, which is how you bridge into APNs/FCM for a backgrounded mobile app.
The adoption reality: servers are ready, clients are not
Here's the honest part. On the server side in 2026, JMAP is production-grade: Stalwart (Rust, JMAP-native), Cyrus IMAP 3.x, and Apache James all speak it, and Fastmail — where JMAP was designed by Neil Jenkins and Bron Gondwana — has run it in production for years.
The client side is thin. In practice you have Fastmail's own web and mobile apps, plus a handful of libraries: jmap-client (JavaScript) and Ltt.rs (Android). Thunderbird is still IMAP-only despite a years-old feature request. Apple Mail and Outlook: nothing native.
So don't plan to hand JMAP to your users' existing desktop clients — that path doesn't exist yet. JMAP is the API for the surfaces you control: your webmail, your mobile app, your automation and integrations. Treat it as the modern API surface for the mail you already run.
Running JMAP alongside IMAP: the dual-stack pattern
You don't migrate. You run both protocols against the same mailboxes.
Pattern A — one server, both protocols, same mailstore. Stalwart, Cyrus, or Apache James expose IMAP (143/993) and SMTP submission (587) for legacy clients *and* JMAP for new surfaces, all against the same accounts and the same on-disk mail. No data migration, no dual copies.
Pattern B — a JMAP gateway in front of an existing IMAP backend. If you can't replace the store yet, a proxy (the Fastmail jmap-proxy Perl lineage) translates JMAP to IMAP and back. The cost: the proxy has to *synthesize* JMAP state from IMAP MODSEQ data, so you lose some of the sync elegance and pay extra work per request. It's a bridge, not a destination.
A few deployment details that bite if you miss them:
Auth doesn't cross over. IMAP keeps its SASL app-passwords; JMAP uses scoped, revocable bearer tokens you issue per integration. Do not reuse the account login password as a JMAP token — the whole point is that you can kill one integration's token without touching mail login.
Autodiscovery is a well-known URL. Clients hit https://<domain>/.well-known/jmap; a 302 redirect to the real session URL is legal and common. Keep it on the same HTTP/2 TLS vhost as everything else.
HTTP/2 multiplexing is a real bonus for the downloadUrl — parallel attachment fetches share one connection instead of IMAP's serialized FETCH BODY[].
Sending splits by client. Legacy clients keep submitting over 587/STARTTLS; new surfaces send via JMAP's EmailSubmission + Identity objects, no separate SMTP path needed.
Checklist: adopting JMAP without breaking legacy mail
Pick a dual-stack server (Stalwart, Cyrus 3.x, or Apache James), or front your existing IMAP store with jmap-proxy if you can't replace it yet.
Expose /.well-known/jmap over HTTP/2 TLS and verify the Session resource with curl before you write a line of client code.
Issue scoped bearer tokens per app and per integration; leave IMAP app-passwords untouched.
Keep SMTP submission (587/STARTTLS) alive for legacy clients; use EmailSubmission over JMAP for the surfaces you build.
Build your webmail and mobile app against JMAP; assume desktop users stay on IMAP — that's fine, they're hitting the same mailboxes.
Wire push via EventSource or PushSubscription, never polling — one channel covers every mailbox.
Load-test your batch sizes against the server's advertised maxCallsInRequest and maxObjectsInGet before you ship; the response to over-batching is an error, not a truncation.