Startup Dropped to 3ms, and the First Turn Saw Zero Tools
Non-blocking MCP loading cut startup from 2,907ms to 3ms. The wait did not disappear — it moved into the first turn. Here is where it went, measured, plus a readiness gate that waits only for what a turn actually needs.
The Antigravity CLI 1.1.9 notes carry a single line about MCP servers now loading without blocking interactive startup.
Faster startup. That seemed like the whole story.
I upgraded, restarted, and the difference was obvious right away. So far, so expected.
What caught me came a second later. I typed my usual opening — "search this directory for the config loader" — and the agent answered from its own guess instead of calling the search tool.
I typed the identical thing again. This time it used the tool.
Same prompt, same config, different behavior. The only variable was how quickly I started typing.
Did the wait vanish, or just move?
Non-blocking loading pulls server connection out of the startup path and runs it behind the scenes. The visible wait shrinks.
But nothing about that makes a handshake cheaper. The real cost of connecting and initializing is untouched.
So where does that time go?
That question is too vague to answer by feel. I turned it into something measurable.
MCP servers speak JSON-RPC over stdio. So a minimal server with configurable startup and initialize costs, plus a harness that swaps only the connection strategy, is enough to watch the wait move.
Building the harness
The mock server first. Process startup cost (imports, runtime boot) and initialize response latency are separate parameters, because in production they behave differently — one is fixed per binary, the other scales with what the server does at init.
#!/usr/bin/env python3"""Minimal MCP-ish stdio server: newline-delimited JSON-RPC.Simulates a real server's cold start: boot cost -> initialize cost.usage: mock_server.py <name> <boot_ms> <init_ms> <tool_ms>"""import sys, json, timename, boot_ms, init_ms, tool_ms = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])time.sleep(boot_ms / 1000.0) # process boot / module import costfor line in sys.stdin: line = line.strip() if not line: continue req = json.loads(line) m = req.get("method") if m == "initialize": time.sleep(init_ms / 1000.0) res = {"protocolVersion": "2025-06-18", "serverInfo": {"name": name}} elif m == "tools/list": res = {"tools": [{"name": f"{name}_query"}]} elif m == "tools/call": time.sleep(tool_ms / 1000.0) res = {"content": [{"type": "text", "text": "ok"}]} else: res = {} sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": req.get("id"), "result": res}) + "\n") sys.stdout.flush()
Then the harness. Three landmarks, kept deliberately distinct — collapse them and the relocation becomes invisible.
t_prompt — when the user can submit a first turn
t_ready — when every server has finished initialize and tools/list
t_first — when the first tool call returns
The server lineup mirrors a realistic mcp_config.json: several cheap ones, one or two expensive ones.
#!/usr/bin/env python3import json, subprocess, sys, threading, timeSERVERS = [ # (name, boot_ms, init_ms, tool_ms) ("fs", 120, 40, 15), ("github", 180, 320, 60), ("db", 150, 210, 35), ("search", 140, 890, 45), # the expensive one ("chrome", 260, 480, 110),]def spawn(spec): n, b, i, t = spec return subprocess.Popen( [sys.executable, "-u", "mock_server.py", n, str(b), str(i), str(t)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)def rpc(p, method, _id): p.stdin.write(json.dumps({"jsonrpc": "2.0", "id": _id, "method": method}) + "\n") p.stdin.flush() return json.loads(p.stdout.readline())def handshake(spec, procs, done, lock): p = spawn(spec) rpc(p, "initialize", 1) rpc(p, "tools/list", 2) with lock: procs[spec[0]] = p done.append((spec[0], time.perf_counter()))def run(strategy): t0 = time.perf_counter() procs, done, lock = {}, [], threading.Lock() if strategy == "blocking": for s in SERVERS: handshake(s, procs, done, lock) t_prompt = time.perf_counter() - t0 else: ths = [threading.Thread(target=handshake, args=(s, procs, done, lock)) for s in SERVERS] for t in ths: t.start() t_prompt = time.perf_counter() - t0 # the input box is usable immediately for t in ths: t.join() t_ready = time.perf_counter() - t0 # aim the first tool call at the slowest server -- the realistic worst case t_call0 = time.perf_counter() rpc(procs["search"], "tools/call", 3) t_first = time.perf_counter() - t0 call_wall = time.perf_counter() - t_call0 for p in procs.values(): p.stdin.close(); p.terminate() return dict(strategy=strategy, t_prompt=t_prompt, t_ready=t_ready, t_first=t_first, call_wall=call_wall)if __name__ == "__main__": for s in ("blocking", "nonblocking"): rows = [run(s) for _ in range(3)] med = lambda k: sorted(r[k] for r in rows)[1] print(f"{s:12s} t_prompt={med('t_prompt')*1000:7.1f}ms " f"t_ready={med('t_ready')*1000:7.1f}ms " f"t_first={med('t_first')*1000:7.1f}ms " f"call_wall={med('call_wall')*1000:6.1f}ms")
Three runs, median reported. A single run is dominated by process-spawn jitter.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Blocking vs non-blocking measured at three separate landmarks, showing that the wait relocates rather than disappears
✦The inverted relationship nobody expects: the faster startup gets, the emptier the tool catalogue is when the first turn lands
✦A complete readiness gate that blocks only on the servers a turn needs, with per-server deadlines and an explicit degraded result
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
t_prompt went from 2,907ms to 3.4ms. Three orders of magnitude. No wonder it feels different.
t_ready, however, is still 1,064.6ms. Not zero.
And t_first lands at 1,110.5ms. The tool call itself only costs 45.8ms, so the remaining 1,064ms is pure waiting for servers to become usable.
The wait did not disappear. It moved out of startup and into the first turn.
The gap between the two rows is the gap between a sum and a maximum. Blocking's 2,907ms is five handshakes added together; non-blocking's 1,064ms is essentially the slowest single server. Adding more fast servers does not move the second number at all. Only making the heavy one lighter does.
The part that ran opposite to expectation
Stated that way, this is a mild result: faster, but the wait persists.
What actually surprised me showed up when I listed per-server readiness.
Server
Ready at (median of 3)
fs
186.3ms
db
384.4ms
github
532.7ms
chrome
766.9ms
search
1,055.9ms
Readiness is staggered. Which means the tool catalogue an agent can see depends entirely on when the first turn arrives.
Here is that visibility, sampled by arrival time.
Turn arrives at T
Servers visible
Coverage
100ms
0 / 5
0%
250ms
1 / 5
20%
500ms
2 / 5
40%
750ms
3 / 5
60%
1,000ms
4 / 5
80%
1,250ms
5 / 5
100%
A t_prompt of 3.4ms means the user can start typing 3.4ms in.
At that moment, zero tools are visible.
This is the inversion. The faster startup becomes, the earlier the first turn lands, and the earlier it lands, the emptier the catalogue it sees. Optimizing startup directly raises the odds of hitting an empty catalogue.
Worse, this never presents as a wait. If a tool is not in the catalogue, the agent does not think "not yet, I should hold." It plans as though the capability does not exist and answers from inference instead.
Blocking was slow but honest. Non-blocking is fast and quietly starts with fewer capabilities than the config promises. That is exactly the "same prompt, different answer" I hit on the first day.
Failures that arrive as plausible answers rather than errors are the expensive kind in production.
Wait for what the turn needs, and nothing else
The obvious fix is to block the first turn until every server is up. It looks like the safe choice.
It also throws away the entire benefit of loading without blocking.
What a turn actually requires is a subset. A turn that reads a file has no reason to wait on a browser server.
So the gate goes on the required set only.
#!/usr/bin/env python3"""Readiness gate: block only on the servers this turn needs,with per-server deadlines and an explicit degraded result."""import threading, timefrom harness import SERVERS, spawn, rpcclass Registry: def __init__(self, specs): self.events = {n: threading.Event() for n, *_ in specs} self.procs, self.failed = {}, {} self._lock = threading.Lock() for s in specs: threading.Thread(target=self._load, args=(s,), daemon=True).start() def _load(self, spec): name = spec[0] try: p = spawn(spec) rpc(p, "initialize", 1) rpc(p, "tools/list", 2) with self._lock: self.procs[name] = p except Exception as e: with self._lock: self.failed[name] = repr(e) finally: self.events[name].set() # fire even on failure: never strand a waiter def await_set(self, required, deadline_ms): """Block only on `required`. Returns (ready, missing).""" end = time.perf_counter() + deadline_ms / 1000.0 ready, missing = [], [] for n in required: remain = end - time.perf_counter() if remain > 0 and self.events[n].wait(remain) and n in self.procs: ready.append(n) else: missing.append(n) return ready, missing
The finally block is the load-bearing detail.
My first version called set() only on the success path. When a server failed to start, its Event never fired, and every waiter sat until the deadline expired. Failure surfaced as the maximum possible wait rather than an immediate error.
The one case that should return fastest was the one case that returned slowest. Moving set() into finally is what fixed it — and I did not see it until I deliberately killed a server mid-run.
Varying the required set:
Gate scope
Wait (median of 3)
All servers
1,057.4ms
fs, db
386.9ms
fs only
184.6ms
search only
1,065.9ms
1,057.4ms for everything, 386.9ms for fs plus db. A 2.7x difference.
And nothing about safety degrades. A server you did not wait for takes no part in the turn's plan. Waiting on it was the excess.
When the deadline passes, the degradation is stated rather than swallowed:
A non-empty missing means telling the agent that search is unavailable this turn, or holding the turn entirely. Suppressing it puts you right back at the original symptom.
Something I tripped over while measuring
One run went out without -u in the Popen arguments.
The child buffered its stdout, and readline() never returned. From the harness, that is indistinguishable from a server that simply does not answer.
The awkward part: a server with an expensive initialize and a server wedged behind output buffering produce identical measurements. Both leave a thread parked on readline() with nothing to tell them apart until a timeout fires.
What I settled on is unglamorous: have the child emit a periodic progress line during handshake, and treat sustained silence as stuck. Lines still arriving means slow; lines stopped means wedged. That separates most cases.
If you run your own servers, make line buffering explicit now and save yourself the diagnosis later. -u for Python, an explicit flush after writes for Node.
How this lands in practice
The policy I am running with:
Keep non-blocking on. A three-order improvement in t_prompt is real. There is no case for giving it back
Gate on the required set. Everything costs 1,057ms; a realistic subset costs 187–387ms. If each agent definition lists the servers it uses, that set is known statically
Set the deadline from the heaviest server.t_ready is decided by the slowest one. A deadline derived from the average guarantees that server always fails
Always surface missing. Refusing to degrade silently is the single highest-value part of this design
Nothing shrinks t_ready except making the heavy server lighter. Once loading is concurrent, the maximum dominates. Trimming the count of fast servers changes nothing you can feel
Points 3 and 5 are the same observation from two directions. The moment loading goes concurrent, tuning stops being about the fleet and becomes about one server.
If you run several MCP servers together, this readiness check belongs in the same place as the preflight that catches tool name collisions before startup. Both answer the same question: is the configuration what we think it is, before the agent starts acting on it?
What I would measure next
These numbers only cover the window from launch to first turn.
Long sessions where a server dies and reconnects can produce the same silent capability loss mid-flight. I have not measured that path yet.
Start with one measurement of your own: count the servers in your mcp_config.json and time each handshake once. Knowing the slowest one in milliseconds is enough to set the deadline. The harness above works against real servers by swapping the mock command for your actual launch command.
Deciding the design after the numbers arrive turns out to remove most of the guesswork.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.