Connecting a discord.js Bot to Lavalink With Shoukaku
Connect a discord.js v14 bot to a Lavalink v4 node with Shoukaku — node setup, joining voice, resolving tracks, a simple queue, events and handling disconnects.
On this page
Shoukaku is a lightweight, stable Lavalink client for discord.js with full Lavalink v4 support. It gives you node management, players and events without imposing a queue system, so you stay in control of how your bot behaves. This guide builds a minimal music bot on top of it.
You’ll need a discord.js v14 bot, a Lavalink v4 node (host, port and password), and Node.js 18 or newer.
Install
npm install discord.js shoukaku
Store your node details as environment variables:
DISCORD_TOKEN=...
LAVALINK_HOST=your-node-host
LAVALINK_PORT=2333
LAVALINK_PASSWORD=your-node-password
Required intents
Voice needs the GuildVoiceStates intent so the bot knows who’s in which channel, and so Shoukaku can forward voice updates to Lavalink:
const { Client, GatewayIntentBits } = require('discord.js');
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates],
});
Connect Shoukaku to your node
const { Shoukaku, Connectors } = require('shoukaku');
const nodes = [
{
name: 'main',
url: `${process.env.LAVALINK_HOST}:${process.env.LAVALINK_PORT}`,
auth: process.env.LAVALINK_PASSWORD,
},
];
const shoukaku = new Shoukaku(new Connectors.DiscordJS(client), nodes, {
resume: true, // resume the session after a short disconnect
resumeTimeout: 60, // seconds Lavalink keeps players while we reconnect
reconnectTries: 10,
moveOnDisconnect: false,
});
shoukaku.on('ready', (name) => console.log(`Lavalink node ${name} connected`));
shoukaku.on('error', (name, error) => console.error(`Lavalink node ${name} error:`, error));
shoukaku.on('close', (name, code, reason) => console.warn(`Node ${name} closed: ${code} ${reason}`));
shoukaku.on('disconnect', (name) => console.warn(`Node ${name} disconnected`));
Always attach an error listener. Without one, a node error is an unhandled error event, which crashes a Node.js process.
Create Shoukaku before calling client.login(), so the connector sees the client’s ready event.
What happens when the bot joins voice
It helps to understand the handshake, because most “joins but no sound” problems happen here.
- Your bot asks Discord’s gateway to join a voice channel.
- Discord replies with two events: a voice state update (your bot’s session in that channel) and a voice server update (which regional voice server to use, plus a token).
- The client library forwards both to Lavalink. This is why the voice states intent matters — without it, the library never sees the events.
- Lavalink connects to that voice server itself and starts streaming audio once you play a track.
Your bot never touches the audio. If the bot shows as connected but there’s silence, the problem is almost always on the Lavalink side — a source that failed to load, a node that can’t reach the voice server, or missing Speak permission — so the node’s logs and your client’s exception events are the first place to look.
A /play command
This handler joins the user’s voice channel, resolves the query and plays the first result, queueing anything requested while a track is playing:
const queues = new Map(); // guildId -> array of encoded tracks
async function play(interaction) {
const query = interaction.options.getString('query', true);
const voice = interaction.member.voice.channel;
if (!voice) return interaction.reply({ content: 'Join a voice channel first.', ephemeral: true });
await interaction.deferReply();
const node = shoukaku.getIdealNode();
if (!node) return interaction.editReply('No Lavalink node is available right now.');
const identifier = /^https?:\/\//.test(query) ? query : `ytsearch:${query}`;
const result = await node.rest.resolve(identifier);
let track;
if (result?.loadType === 'track') track = result.data;
else if (result?.loadType === 'search') track = result.data[0];
else if (result?.loadType === 'playlist') track = result.data.tracks[0];
if (!track) return interaction.editReply('Nothing found.');
let player = shoukaku.players.get(interaction.guildId);
if (!player) {
player = await shoukaku.joinVoiceChannel({
guildId: interaction.guildId,
channelId: voice.id,
shardId: interaction.guild.shardId,
deaf: true,
});
player.on('end', () => playNext(interaction.guildId));
player.on('exception', (e) => console.error('Track exception:', e.exception?.message));
player.on('stuck', () => playNext(interaction.guildId));
player.on('closed', () => cleanup(interaction.guildId));
}
const queue = queues.get(interaction.guildId) ?? [];
queues.set(interaction.guildId, queue);
if (player.track) {
queue.push(track.encoded);
return interaction.editReply(`Queued **${track.info.title}**`);
}
await player.playTrack({ track: { encoded: track.encoded } });
await interaction.editReply(`Now playing **${track.info.title}**`);
}
async function playNext(guildId) {
const player = shoukaku.players.get(guildId);
const next = queues.get(guildId)?.shift();
if (!player) return;
if (next) await player.playTrack({ track: { encoded: next } });
else setTimeout(() => {
if (!shoukaku.players.get(guildId)?.track) cleanup(guildId);
}, 120_000); // leave after two idle minutes
}
async function cleanup(guildId) {
queues.delete(guildId);
await shoukaku.leaveVoiceChannel(guildId);
}
A few details:
loadTypein Lavalink v4 is one oftrack,playlist,search,emptyorerror. Handleemptyanderrorby telling the user nothing was found.ytsearch:searches YouTube through your node’s YouTube plugin. Usescsearch:for SoundCloud, orspsearch:if your node has LavaSrc with Spotify configured.- Deferring the reply first avoids Discord’s three-second interaction timeout while the track resolves.
deaf: trueself-deafens the bot, which saves bandwidth because it won’t receive audio.
Shoukaku’s API has evolved across major versions; if a method name differs in the version you install, check its documentation — the overall flow stays the same.
Controls: skip, pause, volume, stop
const player = shoukaku.players.get(interaction.guildId);
await player.stopTrack(); // skip — triggers 'end', which plays the next track
await player.setPaused(true); // pause (false to resume)
await player.setGlobalVolume(50); // 0–1000; 100 is normal
await cleanup(interaction.guildId); // stop and leave
Filters such as bass boost or nightcore are applied with player.setFilters({...}) — see Lavalink audio filters.
Survive restarts and disconnects
- Resume. With
resume: trueand aresumeTimeout, Lavalink keeps players alive for that many seconds when the WebSocket drops. If your bot reconnects in time — for example after a quick redeploy — playback continues. - Persist queues. The in-memory
Mapabove is lost when the bot restarts. For a bot people rely on, store queues in Redis or a database keyed by guild ID, and rebuild them on startup. - Handle node loss. Listen for
disconnectand let users know if playback stops. With several nodes configured, Shoukaku can pick another node for new players — see running multiple Lavalink nodes.
Troubleshooting
No Lavalink node is available — the node isn’t connected. Check host, port and password, and that your server can reach the node (firewall rules on port 2333).
The bot joins but there’s no sound — look for exception events. Often the source is failing (for example, an outdated YouTube plugin on the node), or the bot lacks the Speak permission.
Unexpected server response: 401 — wrong password.
Playback stops when the bot redeploys — enable resuming, and make sure the bot reconnects within resumeTimeout.
Works locally, fails in production — the production server can’t reach the node, or environment variables aren’t set.
Where to host each part
Host the bot close to Discord for fast commands, and the node close to your listeners for the best audio — see choosing a Lavalink region. Kerit Cloud’s managed Lavalink gives you host, port and password ready for Shoukaku, and the Discord bot plans run the bot itself.
Summary
Shoukaku connects discord.js to Lavalink with a Connectors.DiscordJS connector and a list of nodes. Add the GuildVoiceStates intent, attach an error listener, resolve queries with node.rest.resolve(), join with joinVoiceChannel(), and play with playTrack(). Drive your queue from the end event, enable session resuming, persist queues if they must survive restarts, and leave idle channels.