I was reworking an article index page one morning when completions stopped coming back. Hover tooltips went blank too. Restarting the language server bought me a few minutes, and then it sank again in the same spot. On the third restart I stopped and sat back.
I had been looking for the cause inside configuration files. What I had never once looked at was the number sitting in front of all of that — how many files the editor was holding, and how few of them were actually code.
Here are the commands I ran that morning, and the numbers they gave me.
When a restart does not help, you are probably blaming the wrong layer
It is easy to say "the language server is slow" and leave it there. Inside the editor, though, at least three layers are looking at three different sets of files.
| Layer | What decides its scope | Does a restart change the scope? |
|---|---|---|
| Language server project | tsconfig.json include / exclude | No — same config, same set of files, read again |
| File watching and workspace indexing | Workspace root and exclusion settings | No |
| Agent code search | Tracked files and .antigravityignore | No |
The symptoms usually point at a layer. Completions failing while search still works points at the first. The whole window freezing right after a save points at the second. Long waits only when you ask the agent to look something up points at the third. When all three feel slow at once, the shared volume is the more likely cause.
None of them shrink when you restart. Only the state gets rebuilt. So "it comes back for a few minutes" reads more honestly as: the state rebuilds fine, and then the same volume drags it back down to the same place.
Restarts are for symptoms; exclusions are for volume. Once I wrote that line down, counting became the first thing I do in the morning.
Start by counting the repository
The counting itself is two commands. Total tracked files, then how many of them are code.
# total tracked files
git ls-files | wc -l
# breakdown by extension, top entries only
git ls-files | sed 's/.*\.//' | sort | uniq -c | sort -rn | head -8The repository I work in is a Next.js site that keeps its articles as MDX. This is what came back.
| Group | Files | Bytes | Share of bytes |
|---|---|---|---|
| All tracked files | 2,300 | 34,383,649 | 100% |
.ts and .tsx | 71 | 470,510 | 1.4% |
Under content/ (MDX) | 2,183 | 31,874,978 | 92.7% |
Everything except content/ and public/content/ | 117 | 2,523,152 | 7.3% |
I always read file count and byte count together. Either one alone will send you after the wrong layer.
| Group | Directories | Files | Average file size |
|---|---|---|---|
content/ | 22 | 2,183 | about 14,600 bytes |
src/ | 41 | 74 | about 6,600 bytes |
content/ packs 2,183 files into 22 directories. To a watcher that registers interest per directory, that is cheap. To anything that walks per file, it is the whole cost of the repository. Lopsided shapes like this are where "watching is fine but indexing never finishes" comes from.
That table changed my mind on the spot. The language server project is 71 files. The include list in tsconfig.json is **/*.ts and **/*.tsx, so not a single MDX file ever reaches it.
The thing I had restarted three times was the lightest layer in the editor. The weight was in the other two, the ones that hold everything regardless of extension.
Keep a floor: how long it takes to simply read the files
To tell whether a change to scope did anything, the plainest possible measurement is enough. Open every file, read it, and stop there. No parsing, no indexing — a floor, not a benchmark.
import subprocess, time
out = subprocess.run(["git", "ls-files", "-z"], capture_output=True).stdout
files = [f.decode() for f in out.split(b"\0") if f]
code = [f for f in files if f.endswith((".ts", ".tsx", ".mjs", ".js"))]
def read_all(paths):
start = time.perf_counter()
for p in paths:
with open(p, "rb") as h:
h.read()
return time.perf_counter() - start
for label, paths in (("all", files), ("code-only", code)):
runs = sorted(read_all(paths) for _ in range(5))
print(f"{label:10s} n={len(paths):5d} best={runs[0]*1000:.1f}ms median={runs[2]*1000:.1f}ms")On my machine:
all n= 2300 best=28.8ms median=29.4ms
code-only n= 76 best=0.8ms median=0.8msThirty-six times. I would rather be careful about how that number is read, though. The page cache was warm, and reading is all this does. Parsing, indexing, and embedding generation all stack on top of it, and a cold start or a burst of watch events costs many multiples of this floor.
So I never present this as proof of speed. I run the same command before and after changing exclusions, and I look only at how far the volume dropped. That way the answer arrives as a difference rather than as a feeling.
My exclusions were failing because of square brackets
Now for the exclusions. .antigravityignore uses gitignore syntax, which means git itself can tell you whether a pattern matches.
# write one pattern to a temp file and ask for a verdict
printf '%s\n' 'src/app/[locale]/' > /tmp/probe-ignore
git -c core.excludesFile=/tmp/probe-ignore \
check-ignore --no-index -v 'src/app/[locale]/HomeClient.tsx'Nothing came back. No match. I lined up a few paths that should not have matched either, and got this.
| Pattern | src/app/[locale]/HomeClient.tsx | src/app/l/HomeClient.tsx | src/app/x/page.tsx |
|---|---|---|---|
src/app/[locale]/ | no match | match | no match |
src/app/\[locale\]/ | match | no match | no match |
**/[locale]/** | no match | match | no match |
The brackets were being read as a character class. [locale] means "any one of l, o, c, a, e", so it matches src/app/l/ and src/app/c/ while leaving the real src/app/[locale]/ untouched. The directory I meant to exclude stayed in, and directories I never intended to touch went out.
With the App Router those brackets are everywhere. In my repository, 21 tracked paths contain them, and [category] and [slug] behave exactly the same way.
The fix is to escape the brackets, or to name a parent directory that has none. Either way, count what is left afterwards.
# how many files survive the exclusions
TOTAL=$(git ls-files | wc -l)
IGN=$(git ls-files -z \
| git -c core.excludesFile=/tmp/probe-ignore check-ignore --no-index --stdin -z \
| tr -cd '\0' | wc -c)
echo "tracked=$TOTAL ignored=$IGN remaining=$((TOTAL-IGN))"With just two lines, content/ and public/content/, mine printed tracked=2300 ignored=2183 remaining=117. One command tells you whether the drop matches what you expected.
If the matching itself is what keeps failing, Four Reasons Your .antigravityignore Rules Are Not Taking Effect covers slash placement and negation ordering, which narrows things down faster.
A word on how I take the measurement. I run it five times and report both the best and the median. A single run can land on the moment some other process happens to be busy, and if those two values are far apart I go looking at what else is running before I touch scope at all.
And if the full set and the code-only set come out close to each other, volume is not the problem. That is when I switch to hunting for one pathological file — a generated bundle, or a file with a single enormous line. The same 30 milliseconds means something entirely different depending on whether it is spread across two thousand files or concentrated in one.
What I exclude, and what I deliberately leave in
Once you know the volume, the next question is how much of it to remove. I sort candidates into three tiers.
| Tier | Examples | How I treat it |
|---|---|---|
| Generated output | .next/, out/, build artifacts, coverage | Exclude without hesitation — it can be regenerated |
| Dependencies | node_modules/ | Usually excluded by default already; no need to repeat it |
| Things you wrote | MDX under content/, design notes | Check what stops being searchable before excluding |
The third tier is where I got it wrong. I excluded all of content/ in one go, and then asked the agent whether an article on a given topic already existed. It found nothing. The volume dropped nicely, and the task I lean on most often quietly stopped working.
A cheap way to avoid that trade is to record one search you actually rely on, before and after.
# note how many hits the search you depend on returns, before excluding anything
git grep -l 'antigravityignore' -- content/articles/ja | wc -lMine returned 26. If the same command comes back smaller after the change, the exclusion went too far.
The other habit worth keeping is separating exclusions by purpose. Making file watching lighter and narrowing the agent's search surface are two different goals, and they live in different places. Copying the same few lines into both tends to overshoot on one side and fall short on the other.
Where I draw the line now
My morning routine got shorter after this. When completions stop, I put the tracked file count and the code file count side by side. If there are two orders of magnitude between them, the problem is not the language server — it is whichever layer around it is holding everything.
The reverse case is real too. In a repository with several thousand actual source files, exclusions are not the lever; narrowing the tsconfig.json include list is. Counting once is what tells you which situation you are in.
If you do one thing today, print git ls-files | wc -l next to the number of .ts and .tsx files, in the same view. If you would rather not leave the editor for it, the sidebar terminal I wrote about in Building a Verification Loop With Antigravity 2.10.0's Embedded Terminal handles this well.
I write those two numbers down before I add a single line of exclusions. Thank you for reading this far.