How lazy loading works in Neovim June 14, 2024 source/commit

#What’s lazy loading?

Lazy loading is postponing the loading/initialization of modules until they are required. If we have a plugin for a specific programming language, we don’t need to use it until we edit a file in said language, so we don’t run it immediately at startup, but wait until we encounter a specific filetype.

If you are someone who only recently started using Neovim and uses some prebuilt configuration like kickstart.nvim or LazyVim1 you are already doing this. Both of them use lazy.nvim, which does its best to make sure you don’t load anything unless you need it.

But you don’t actually have to use a complex plugin manager to do this and it can be implemented in like 30 lines of code using builtin Neovim functionality.2

#How does it work?

Neovim and modern Vim have a builtin package system (see: :h packages). Plugin managers may place installed plugins somewhere in your 'runtimepath', in a directory that looks like “pack/*/start” or “pack/*/opt” where the star is any arbitrary name that the plugin manager chooses3.

The “start” directory contains all plugins which are loaded automatically and “opt” contains plugins which only run on demand. To load such a plugin we can use the :packadd command. The only issue is that we don’t want to do this manually.

#Registering plugins

Let’s name the script “ll”, because short names are cute. The script will need some way to register a plugin and then load it at an appropriate time. Its usage will look something like this:

1
2
3
local ll = require('ll')

ll.register('telescope.nvim', { commands = { 'Telescope' } })

We require it, call register on a plugin and specify that we want to load it once we invoke :Telescope, and it should somehow figure out what to do that with that information.

Inside ll.lua:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
local M = {}

M.register = function(pkgname, opts)
    for _, cmd in ipairs(opts.commands)
    do
        vim.api.nvim_create_autocmd('CmdUndefined', {
            pattern = cmd,
            callback = function() vim.cmd.packadd(pkgname) end,
            once = true,
        })
    end
end

return M

We create an autocommand that triggers whenever undefined command matching one of the commands we specified executes and then we :packadd.

Similar autocommands can be easily made for any event (FileType for example).

But what about setup functions and requiring lua modules?

#Setup functions

Most Neovim plugins follow the convention of doing require('plugin').setup() to initialize them or override default settings. We cannot call these from our init.lua if the plugin isn’t loaded yet. Let’s just take a setup function as part of the options:

1
2
3
4
5
6
ll.register('telescope.nvim', {
    commands = { 'Telescope' },
    setup = function()
        require('telescope').setup({ <my telescope config> })
    end,
})

And back in the module’s file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
CALLBACKS = {}

M.load = function(pkgname)
    local setup = CALLBACKS[pkgname]
    if setup
    then
        vim.cmd.packadd(pkgname)
        setup()
        CALLBACKS[pkgname] = nil
    end
end

M.register = function(pkgname, opts)
    CALLBACKS[pkgname] = opts.setup or function() end

    -- the same as before with the CmdUndefined, but:
    -- callback = function() M.load(pkgname) end,
end

#Requiring modules

To require a module of an unloaded plugin I also keep track of modules the plugin contains, this information is also passed as part of the register call.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- ...

MODULES = {}

M.register = function(pkgname, opts)
    -- ...

    for _, mod in ipairs(opts.modules or {}) do MODULES[mod] = pkgname end
end

M.require = function(modname)
    if MODULES[modname]
    then
        M.load(MODULES[modname])
        MODULES[modname] = nil
    end

    return require(modname)
end

The only caveat is that now instead of using require I have to use ll.require for requiring lazy loaded modules. I prefer this, because of its explicitness, but it is possible to overwrite the global require call to add custom loading logic to resolve and load necessary plugins.

1
2
3
4
5
local _require = require
_G.require = function(modname)
    -- my custom require logic
    return _require(modname)
end

But I’ll leave that as an exercise for you (the reader) if you wish to use that.

#Conclusion

If you use a plugin manager that uses (Neo)vim’s native package system, and it doesn’t have lazy loading it is trivial to implement it.

You don’t need a plugin manager in fact! If you use git, (Neo)vim’s builtin mechanisms and techniques described here you can achieve most of the things a plugin manager does.

To learn how to manage plugins with plain old git you can read this great blogpost by HiPhish.

The code in this blogpost is incomplete for the sake of brevity, if you are interested you can check out the version I use which also handles filetypes, help tags and has type validation and annotations.


  1. Chosen because of its popularity, I actually don’t recommend using a Neovim “distros” like LazyVim (either use kickstart or start from scratch). ↩︎

  2. Obviously lazy.nvim does a lot more things than my simple module. ↩︎

  3. Only the ones that follow this convention, lazy.nvim doesn’t actually use this and takes over the entire initialization of Neovim. ↩︎

Have something to say? Feel free to leave your thoughts in my public inbox, comment on an associated social media post (HN, Lemmy) or contact me personally.