Almost every Discord ticket bot creates a channel per ticket. It is the obvious design: a channel is private by default, it holds a conversation, and Discord gives you an API for making one. We built the first version that way too.

It works fine for a server with five tickets a week. The problems arrive with volume, and they arrive all at once.

Guilds have a channel ceiling

A Discord guild holds 500 channels, and categories hold 50 each. Those are hard limits. A busy community can generate a few hundred tickets in a month, which means a channel-based bot spends a meaningful share of its engineering effort on archiving strategies: when to delete, what to keep, how to warn an admin that the server is nearly full.

None of that work makes support better. It exists purely to manage a constraint the design chose.

Every channel is a permission problem

Opening a ticket as a channel means writing permission overwrites: the opener can see it, the support role can see it, everyone else cannot. That is one API call per ticket at best, and each overwrite is state that can drift. Add a member to the ticket later and you are editing overwrites again.

Threads inherit from their parent. A private thread in your support channel is already scoped to the people who can see that channel, and adding someone is adding a member, not rewriting an access-control list. The permission model becomes something you configure once, on one channel, instead of something the bot recomputes hundreds of times.

Rate limits are the real ceiling

Channel creation is one of the more heavily limited operations in the Discord API, and the limit is per guild. During a spike (an outage, a launch, a raid) a channel-per-ticket bot hits that ceiling exactly when it can least afford to, and members watch a button do nothing.

The practical shape of the fix is a queue with backoff, which every mature channel-based bot ends up writing:

javascript
// The shape every channel-per-ticket bot converges on.
// Creating the channel is the slow, rate-limited part, so it
// gets queued, and the member waits on a queue for a UI
// element that should have been instant.
async function openTicket(guild, member) {
  const channel = await queue.add(() =>
    guild.channels.create({
      name: `ticket-${nextId()}`,
      parent: SUPPORT_CATEGORY,
      permissionOverwrites: [
        { id: guild.roles.everyone, deny: ["ViewChannel"] },
        { id: member.id, allow: ["ViewChannel", "SendMessages"] },
        { id: SUPPORT_ROLE, allow: ["ViewChannel", "SendMessages"] },
      ],
    }),
  );
  return channel;
}

The thread version has no queue, no overwrites, and no category to run out of:

javascript
// The thread inherits the support channel's permissions,
// so scoping is configuration rather than per-ticket state.
async function openTicket(supportChannel, member) {
  const thread = await supportChannel.threads.create({
    name: subjectFor(member),
    type: ChannelType.PrivateThread,
    invitable: false,
  });
  await thread.members.add(member.id);
  return thread;
}

What threads cost

This is not a free win, and it is worth being honest about the trade. Threads archive, and an archived thread has to be unarchived before it can receive a message, so a bot has to handle reopening rather than assuming a channel is always live. Private threads are a paid-tier feature on some server levels. And a thread lives under one parent channel, so the "category" structure some teams like has to be expressed with several parent channels instead of nested categories.

Those are real constraints. They are also constraints that stay constant as you grow, which is the difference that mattered to us: the channel design gets worse the more successful your community becomes, and the thread design does not.