Profiling Neovim startup: lazy-loaded plugins that weren't lazy

Update 2026-10-17: the next pass looked at big log files, and found an
ncmapping that made everynwaittimeoutlen(500 ms) in every file. See Profiling Neovim on big logs.
After tmux and
zsh, I looked at
Neovim. My config has grown to 138 plugins on lazy.nvim and LazyVim. Almost
every spec has an event, cmd or keys, so I assumed most of them were lazy.
They weren’t: 89 of the 138 plugins loaded at startup.
None of the causes was a slow plugin. They were config mistakes that only show up once you ask lazy.nvim why each plugin loaded. I fixed them in two rounds:
| before | after | |
|---|---|---|
| CPU time to first screen, opening a new file | ~150 ms | ~120 ms |
| CPU time to first screen, no file (dashboard) | 69 ms | 50 ms |
| plugins loaded at startup | 89 | 73 |
The setup
- Neovim v0.12.5, LuaJIT 2.1
- lazy.nvim with LazyVim as the base, pinned to a May 2024 commit
- 138 plugin specs under
~/.config/nvim/lua/plugins/, plus old ones indisabled/,tmp/andx/ vim.loader.enable()and lazy’sperformance.rtp.disabled_pluginsalready set, so the usual quick fixes were already done
Step 1: headless numbers are too optimistic
The usual first measurement:
|
|
|
|
63 ms looks fine, but it isn’t what I see when I start Neovim. Headless mode
has no UI, so there’s no UIEnter. Without UIEnter, lazy.nvim never fires
VeryLazy, and everything deferred to VeryLazy doesn’t run.
To measure a real start, Neovim needs a terminal. script gives it a pty. I
also load a small probe with --cmd. At VeryLazy it asks lazy.nvim for its
stats and for the reason each plugin was loaded, writes them to a file, then
quits:
measure.lua:
|
|
|
|
|
|
startuptime is lazy.nvim’s time to UIEnter, which is roughly when the
first screen is drawn (CPU time, see below). The times table splits it up: lazy.nvim started at
10 ms, had set up all startup plugins by 54 ms, and UIEnter came at 138 ms.
So most of the time went to opening the file, after lazy.nvim’s own setup.
A 114 ms trap in the measurement itself
In the pty runs, --startuptime showed one very large entry, before my config
had even been read:
|
|
That’s Neovim’s own runtime. Since 0.10 it asks the terminal for its background
color (OSC 11, plus a DSR query) and waits up to 100 ms for the answer.
script never answers, so every pty run included that wait. :messages says
so:
|
|
A real terminal answers in about a millisecond. So I ignored the total
NVIM STARTED time from these runs and compared lazy.nvim’s startuptime
instead. On Linux, lazy.nvim measures it as process CPU time
(clock_gettime(CLOCK_PROCESS_CPUTIME_ID) in lazy/stats.lua). Time spent
idle, waiting for a reply that never comes, doesn’t count.
One more caution: in one run require('lazyvim.config.autocmds') took
19.7 ms, and in the next two runs 0.07 ms. Always run at least three times
before blaming a line.
Step 2: ask why each plugin loaded
The interesting part of the probe output is the reason column. Sorted, a few patterns stood out right away:
|
|
{ start = "start" }means the spec has no lazy trigger. I setdefaults.lazy = false, so every spec without anevent,cmd,keysorftloads at startup.{ plugin = "X" }means it loaded because X listed it independencies. A dependency always loads together with its parent, even if it has its owncmd.{ ft = "lua" }on a debugger: dap loaded because I opened a Lua file.
Round 1: the obvious ones
| plugin | why it loaded | fix |
|---|---|---|
| remote-sshfs + telescope | event = "VeryLazy", and telescope is its dependency |
cmd + keys |
| nvim-dap + dap-ui | event = "VeryLazy" and ft = {'c','cpp','go','lua','python'} |
keys only |
| diffview, vscode-diff | no trigger | cmd = { "Diffview*" }, cmd = "CodeDiff" |
| namu | no trigger | cmd + keys + a vim.ui.select stub |
| telescope | event = "VeryLazy" |
cmd = "Telescope" |
A few details that matter:
-
remote-sshfs defines
<Leader>ffand<Leader>fgin itsconfig. They replace my normal telescope mappings with versions that search the remote host when connected. If the plugin only loaded on:RemoteSSHFSConnect, those keys would quietly go back to the plain ones until the first connect. So I listed them inkeys. Pressing one loads the plugin, itsconfiginstalls the real mapping, and lazy.nvim replays the keys. -
My dap
rkey still works. It’s anexprmapping that runscontinuein a debug session and falls back to normalr(replace a character) everywhere else (that post). Now that it’s inkeys, the firstrloads dap. I checked in a pty thatrX/rYon two lines still giveXbc/Ybc. -
namu loaded at startup just to take over
vim.ui.select. I checked who owned it in a running session:1 2debug.getinfo(vim.ui.select, "S").source -- @.../lazy/namu.nvim/lua/namu/ui_select/ui_select.luaTo keep namu as the owner without loading it, I install a stub at the same point in startup where namu’s setup used to run, which is
LazyDone. The stub loads namu on the first call:1 2 3 4 5 6 7 8 9 10 11 12init = function() vim.api.nvim_create_autocmd("User", { pattern = "LazyDone", once = true, callback = function() vim.ui.select = function(...) require("lazy").load({ plugins = { "namu.nvim" } }) return vim.ui.select(...) -- namu's setup has replaced it by now end end, }) end,
…and telescope still loaded
After all that, telescope was still there, now for a different reason:
|
|
nvim-neoclip.lua has to start early so it can record yanks. Its spec listed
both pickers it supports as hard dependencies:
|
|
neoclip’s setup() doesn’t use either of them. Only its pickers do, and those
require telescope or fzf-lua when you open them. lazy.nvim loads a lazy
plugin automatically when one of its modules is required. So I removed both
from dependencies, and gave fzf-lua its own lazy spec so it stays installed.
:Telescope neoclip still works.
After round 1: 73 plugins at startup, startuptime 117–125 ms, and about
33 ms less work right after the first screen.
Round 2: opening a file
Round 1 was measured opening a file that doesn’t exist yet (BufNewFile).
Opening a real file also fires BufReadPost and more plugins load. This time
I looked at which requires ran while the buffer was opened:
|
|
|
|
The VSCode snippet loader ran twice, about 15 ms, every time a file was opened, before I had typed anything.
Snippets loaded through a dependency’s config
The chain was: the LSP specs list blink.cmp as a dependency (they need its
capabilities). blink lists friendly-snippets, and that dependency had its own
config. My blink spec is plugins/blink-cmp.lua, which turned out to be a
symlink to x/blink-cmp.new.lua:
|
|
That’s three problems in one block:
- blink doesn’t use LuaSnip. It has its own snippet source.
- My LuaSnip spec already does the same two
lazy_loadcalls onInsertEnter. ~/.config/nvim/snippetsdoesn’t exist. My own snippets live insnips/. That explains the warnings that had grownluasnip.logto 3.4 MB. Back then I lowered LuaSnip’s log level to hide them. Now the cause is gone.
The fix is to keep the dependency and remove its config:
|
|
Checked in a session: LSP still starts with snippet support
(lua_ls(snip)), LuaSnip isn’t loaded until the first InsertEnter, and then
it has 18 Lua and 31 global snippets.
A disabled spec that still ran code
The same profile showed require('cmp') during spec import, although I use
blink and nvim-cmp.lua says enabled = false. The file starts like this:
|
|
A spec file is a Lua module, and lazy.nvim runs all of it while importing
specs. enabled = false only applies to the table it returns. The two
requires at the top run anyway. Headless, without any file:
|
|
So both modules were loaded at startup even though lazy.nvim considered the plugin not loaded. I moved them inside a lazy wrapper:
|
|
lualine was set up twice
theme.lua had cost 12–16 ms in every run. It loads the colorscheme and then
builds my lualine config, starting with:
|
|
That require loads lualine immediately. lazy.nvim then runs LazyVim’s lualine
spec, which builds LazyVim’s statusline and calls setup(). A hundred lines
later, theme.lua calls require('lualine').setup({...}) again with my
config. So there were two full setups at startup, and the first one was
thrown away.
The fix was to move the lualine part of theme.lua unchanged into
lua/statusline.lua, which returns the config table, and pass it as opts:
lua/plugins/lualine.lua:
|
|
Now theme.lua only loads kanagawa, and lualine is set up once, at
VeryLazy. The last ten lines of that spec are there because of a regression
I introduced first.
Deferring setup changed what it saw
My first version only returned the opts. With a file argument, the statusline was identical to before. Started without a file, the dashboard now showed a statusline. Before, the dashboard had hidden it.
snacks’ dashboard hides the statusline by setting laststatus=0, and restores
it when the dashboard closes. To find what turned it back on, I wrapped every
Lua write to vim.o.laststatus:
|
|
|
|
My own fix (set it back to 0 in opts) did run, but something set it to 2
afterwards without going through vim.o. A grep found it in lualine itself:
|
|
lualine decides globalstatus from laststatus at the moment setup()
runs. Before, setup ran during startup, while laststatus was still 3 from
my options, so the statusline was global. At VeryLazy the dashboard had
already set it to 0, so lualine chose per-window statuslines and forced
laststatus=2. Setting globalstatus = true explicitly and hiding the
statusline after setup() restores the old behavior. With these two changes,
four out of four runs ended with laststatus=0 on the dashboard, and 3 after
opening a file.
nvim-tree: two specs, one plugin
The last one. My nvim-tree spec has event = "VeryLazy" because it
opens the tree when Neovim starts without a file.
That shouldn’t apply when I open a file. I changed it to load on :NvimTree*
commands, with an init that loads it at VeryLazy only when there’s no file
argument or the argument is a directory:
|
|
It still loaded on every start. lazy.nvim’s merged spec showed why:
|
|
|
|
|
|
|
|
(disabled/ and tmp/ aren’t imported.)
lazy.nvim merges all specs with the same plugin name. There was an older
plugins/nvim-tree.lua from September next to the one in navigation/. Its
config was replaced by the newer one (later imports win), so it had looked
dead. Its event = "VeryLazy" was still merged in. The preview plugin’s file
also re-declares nvim-tree with event = "VeryLazy". I commented out the event
in both. I also checked that nvim <directory> behaves as before: I swapped
the original files back in and compared.
Verifying behavior, not just speed
Making a plugin lazy can break it without any error. Keys may lose their
mapping, or a plugin may lose ownership of something like vim.ui.select.
After each change I ran a probe in a pty, at VeryLazy, like the timing one:
|
|
|
|
Other probes checked LSP capabilities, LuaSnip snippet counts, lualine’s final
config, the laststatus value in each start mode, <F5> opening the tree,
and dap’s r fallback. Every edited file has a *.bak.20261002 copy next to
it, so the A/B runs below just swapped files.
Results
Round 1 (opening a new file, 3–6 runs each):
| before | after | |
|---|---|---|
lazy.nvim startuptime |
138–170 ms | 118–125 ms |
| plugins loaded at startup | 89 | 73 |
| work right after first screen (remote-sshfs, telescope, dap) | ~33 ms | 0 |
Round 2 (original vs fixed files, median of 7):
| before | after | |
|---|---|---|
| no file (dashboard) | 69.4 ms | 50.2 ms |
| existing file | 201.2 ms, 111 plugins | 194.9 ms, 108 plugins |
Most of the dashboard gain comes from lualine. For an existing file, the gain is within run-to-run noise. LuaSnip no longer loads, but the remaining cost is in other plugins.
What’s left
Opening a real file still loads a lot of plugins on BufRead*:
| plugin | cost | note |
|---|---|---|
| lsp-setup.nvim | 13.7 ms | mason registry checks for ensure_installed |
| opencode.nvim | 13.0 ms | event = "BufRead", could be cmd/keys |
| marker-groups.nvim | 10.2 ms | same |
| lazygit.nvim + telescope | 9.0 + 7.7 ms | pulls telescope back in on every file |
| avante.nvim | 4.4 ms | same |
LazyVim is also pinned to a May 2024 commit. Updating it is overdue, but it’s a bigger change than any of the above.
Takeaways
- Measure in a real terminal, and know what the harness adds. Headless
skips
VeryLazycompletely. A pty without a real terminal adds a 100 ms wait for a reply that never comes. Compare runs measured the same way. - Ask lazy.nvim why each plugin loaded.
p._.loadedcontains the reason.{ start = "start" }and{ plugin = "X" }point directly at the spec to fix. dependenciesmeans “load together with me”. For optional integrations, such as pickers, letrequireload them on demand.- Specs are merged by name. An old duplicate file can still add an
event. Queryrequire("lazy.core.config").spec.plugins[name]to see what lazy.nvim actually uses. - A spec file is code.
enabled = falsedoesn’t stoprequires at the top of the file. - Deferring a setup changes what it sees. lualine reads
laststatuswhensetup()runs. Pin the values that used to come from startup order. - Verify behavior after each change, not only the timing. Every regression in this post was silent. Probes showed them; the timings alone didn’t.
References
- lazy.nvim: lazy loading and spec merging
:help --startuptime,:help 'ttyfast'- Neovim runtime:
lua/vim/_core/defaults.lua(OSC 11 background detection) - lualine.nvim:
lua/lualine/config.lua(globalstatusdefault)
相关内容
- Profiling zsh: a Python plugin was adding 190 ms to every command
- Why my tmux was laggy: the status bar was forking a 1.2 GB server 24 times a second
- tmux lag, round 2: every status-bar fork froze the server for 30 ms
- nvim tree input completion
- nvim ASCII color
支付宝
微信

william