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 nc mapping that made every n wait timeoutlen (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 in disabled/, tmp/ and x/
  • vim.loader.enable() and lazy’s performance.rtp.disabled_plugins already set, so the usual quick fixes were already done

Step 1: headless numbers are too optimistic

The usual first measurement:

1
2
nvim --headless --startuptime st.log +qa
tail -2 st.log
1
2
063.355  000.003: editing files in windows
063.359  000.003: --- NVIM STARTED ---

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
vim.api.nvim_create_autocmd("User", { pattern = "VeryLazy", callback = function()
  vim.defer_fn(function()
    local s = require("lazy").stats()
    local loaded = {}
    for _, p in ipairs(require("lazy").plugins()) do
      if p._.loaded then
        -- p._.loaded holds the reason: { event = ... } / { plugin = ... } / { start = ... }
        table.insert(loaded, string.format("%-40s %6.2fms  %s", p.name,
          (p._.loaded.time or 0) / 1e6, vim.inspect(p._.loaded):gsub("%s+", " ")))
      end
    end
    local f = io.open(vim.env.OUT, "w")
    f:write(string.format("startuptime=%.2fms count=%d loaded=%d\n", s.startuptime, s.count, s.loaded))
    for k, v in pairs(s.times or {}) do f:write(k .. "=" .. v .. "\n") end
    f:write(table.concat(loaded, "\n"))
    f:close()
    vim.cmd("qa!")
  end, 1500)
end })
1
2
3
4
5
6
for i in 1 2 3; do
  OUT=stats$i.txt TERM=xterm-256color \
    script -qc "nvim --startuptime tui$i.log --cmd 'luafile measure.lua' x.lua" /dev/null \
    >/dev/null </dev/null
  head -1 stats$i.txt
done
1
2
3
startuptime=137.55ms count=138 loaded=89
startuptime=170.15ms count=138 loaded=89
startuptime=151.94ms count=138 loaded=89

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:

1
119.938  114.801  114.694: require('vim._core.defaults')

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:

1
E1568: Terminal did not respond to DSR request for 'background' color. Startup may be slower. :help 'ttyfast'

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
remote-sshfs.nvim                         10.50ms  { event = "VeryLazy" }
telescope.nvim                             8.42ms  { plugin = "remote-sshfs.nvim" }
telescope-fzf-native.nvim                  3.68ms  { plugin = "telescope.nvim" }
nvim-dap                                   4.47ms  { ft = "lua" }
nvim-dap-ui                                3.44ms  { plugin = "nvim-dap" }
nvim-tree.lua                              7.85ms  { event = "VeryLazy" }
diffview.nvim                              4.12ms  { start = "start" }
fold-preview.nvim                          3.63ms  { start = "start" }
namu.nvim                                  2.20ms  { start = "start" }
vscode-diff.nvim                           0.93ms  { start = "start" }
lsp-setup.nvim                            24.64ms  { event = "BufNewFile" }
nvim-lspconfig                            22.68ms  { event = "LazyFile" }
blink.cmp                                 17.23ms  { plugin = "nvim-lspconfig" }
friendly-snippets                          8.97ms  { plugin = "blink.cmp" }
LuaSnip                                    6.83ms  { plugin = "friendly-snippets", require = "luasnip.loaders.from_vscode" }
  • { start = "start" } means the spec has no lazy trigger. I set defaults.lazy = false, so every spec without an event, cmd, keys or ft loads at startup.
  • { plugin = "X" } means it loaded because X listed it in dependencies. A dependency always loads together with its parent, even if it has its own cmd.
  • { 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>ff and <Leader>fg in its config. 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 in keys. Pressing one loads the plugin, its config installs the real mapping, and lazy.nvim replays the keys.

  • My dap r key still works. It’s an expr mapping that runs continue in a debug session and falls back to normal r (replace a character) everywhere else (that post). Now that it’s in keys, the first r loads dap. I checked in a pty that rX / rY on two lines still give Xbc / Ybc.

  • namu loaded at startup just to take over vim.ui.select. I checked who owned it in a running session:

    1
    2
    
    debug.getinfo(vim.ui.select, "S").source
    -- @.../lazy/namu.nvim/lua/namu/ui_select/ui_select.lua

    To 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
    12
    
    init = 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:

1
telescope.nvim                            13.22ms  { plugin = "nvim-neoclip.lua" }

nvim-neoclip.lua has to start early so it can record yanks. Its spec listed both pickers it supports as hard dependencies:

1
2
3
4
5
dependencies = {
    -- you'll need at least one of these
    {'nvim-telescope/telescope.nvim'},
    {'ibhagwan/fzf-lua'},
},

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:

1
2
3
4
# --startuptime lines: clock, self+sourced, self, name. Keep the part after
# ShaDa is read (i.e. after init.lua), sort by self+sourced.
awk '/reading ShaDa/{p=1} p' server.log | grep -E '^[0-9.]+ +[0-9.]+ +[0-9.]+:' \
  | awk '{print $2, $4}' | sort -rn | head
1
2
3
4
009.587 require('luasnip.loaders.from_vscode')
005.895 require('luasnip.loaders.from_vscode')
005.227 require('luasnip.util.parser')
...

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:

1
2
3
4
5
6
7
{
  "rafamadriz/friendly-snippets",
  config = function()
    require("luasnip.loaders.from_vscode").lazy_load()
    require("luasnip.loaders.from_vscode").lazy_load({ paths = { vim.fn.stdpath("config") .. "/snippets" } })
  end,
},

That’s three problems in one block:

  1. blink doesn’t use LuaSnip. It has its own snippet source.
  2. My LuaSnip spec already does the same two lazy_load calls on InsertEnter.
  3. ~/.config/nvim/snippets doesn’t exist. My own snippets live in snips/. That explains the warnings that had grown luasnip.log to 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:

1
{ "rafamadriz/friendly-snippets" },

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:

1
2
3
4
5
6
7
8
local M = {
	"hrsh7th/nvim-cmp",
	...
    enabled = false,
}

local cmp = require'cmp'
local luasnip = require'luasnip'

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:

1
cmp=true luasnip=true LuaSnip loaded=false

So both modules were loaded at startup even though lazy.nvim considered the plugin not loaded. I moved them inside a lazy wrapper:

1
local luasnip = setmetatable({}, { __index = function(_, k) return require('luasnip')[k] end })

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:

1
local custom_fname = require('lualine.components.filename'):extend()

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
return {
    "nvim-lualine/lualine.nvim",
    opts = function()
        local opts = require("statusline")
        opts.options.globalstatus = true   -- see below
        return opts
    end,
    config = function(_, opts)
        require("lualine").setup(opts)
        for _, win in ipairs(vim.api.nvim_list_wins()) do
            if vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "snacks_dashboard" then
                vim.o.laststatus = 0
                break
            end
        end
    end,
}

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:

1
2
3
4
5
6
7
local mt = getmetatable(vim.o); local orig = mt.__newindex
mt.__newindex = function(t, k, v)
  if k == "laststatus" then
    log[#log+1] = ("set -> %s  %s"):format(v, debug.traceback("", 2))
  end
  return orig(t, k, v)
end
1
2
3
4
5
set 3 -> 0   lazyvim/plugins/ui.lua:119: in function 'init'
set 3 -> 0   snacks/dashboard.lua:1187: in function 'setup'
set 0 -> 3   lazyvim/plugins/ui.lua:129: in function 'values'
set 3 -> 0   lua/plugins/lualine.lua:14: in function 'values'
final ls=2

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:

1
2
3
4
-- lualine.nvim/lua/lualine/config.lua
globalstatus = vim.go.laststatus == 3,
-- lualine.nvim/lua/lualine.lua
modules.nvim_opts.set('laststatus', 2, { global = true })

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
init = function()
    if vim.fn.argc(-1) == 0 or vim.fn.isdirectory(vim.fn.argv(0)) == 1 then
        vim.api.nvim_create_autocmd("User", {
            pattern = "VeryLazy",
            once = true,
            callback = function()
                require("lazy").load({ plugins = { "nvim-tree.lua" } })
            end,
        })
    end
end,

It still loaded on every start. lazy.nvim’s merged spec showed why:

1
2
nvim --headless -c 'lua local p = require("lazy.core.config").spec.plugins["nvim-tree.lua"]
  io.write("event=", vim.inspect(p.event), " lazy=", tostring(p.lazy), "\n")' +qa
1
event="VeryLazy" lazy=true
1
grep -rln "nvim-tree.lua'\|nvim-tree.lua\"" lua/plugins --include=*.lua | grep -v '\.bak'
1
2
3
4
5
lua/plugins/nvim-tree.lua
lua/plugins/disabled/nvim-tree-preview.lua
lua/plugins/tmp/nvim-tree.lua
lua/plugins/navigation/nvim-tree.lua
lua/plugins/navigation/nvim-tree-preview.lua

(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:

1
2
3
4
5
6
7
8
local P = require("lazy.core.config").plugins
f:write("namu loaded=" .. tostring(P["namu.nvim"]._.loaded ~= nil) .. "\n")
for _, lhs in ipairs({ " ff", " fg", " rc", " ns", " db" }) do
  local m = vim.fn.maparg(lhs, "n", false, true)
  f:write("map<leader>" .. lhs:sub(2) .. "=" .. (m.desc or m.rhs or "?") .. "\n")
end
pcall(vim.ui.select, { "a" }, {}, function() end)
f:write("ui.select=" .. debug.getinfo(vim.ui.select, "S").source .. "\n")
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
remote-sshfs.nvim loaded=false
nvim-dap loaded=false
namu.nvim loaded=false
map<leader>ff=Find files (remote-aware)
map<leader>rc=RemoteSSHfs connect
map<leader>ns=Jump to LSP symbol
map<leader>db=Toggle Breakpoint
namu after ui.select=true
ui.select=@.../lazy/namu.nvim/lua/namu/ui_select/ui_select.lua
diffview cmd ok=true

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

  1. Measure in a real terminal, and know what the harness adds. Headless skips VeryLazy completely. A pty without a real terminal adds a 100 ms wait for a reply that never comes. Compare runs measured the same way.
  2. Ask lazy.nvim why each plugin loaded. p._.loaded contains the reason. { start = "start" } and { plugin = "X" } point directly at the spec to fix.
  3. dependencies means “load together with me”. For optional integrations, such as pickers, let require load them on demand.
  4. Specs are merged by name. An old duplicate file can still add an event. Query require("lazy.core.config").spec.plugins[name] to see what lazy.nvim actually uses.
  5. A spec file is code. enabled = false doesn’t stop requires at the top of the file.
  6. Deferring a setup changes what it sees. lualine reads laststatus when setup() runs. Pin the values that used to come from startup order.
  7. 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 (globalstatus default)

相关内容

william 支付宝支付宝
william 微信微信
0%