Out of N number of participants, draw out M winners It could be a simple Fisher-Yates, but then if the number of participants is 1,000,000 and the number of winner is only 3, then we really don't need to shuffle the whole 1,000,000 numbers, so the following is to reduce the number of steps, written as a simpler case of drawing 9 winners out of 30 participants:
The following is to consider 30 participants, each numbered from 1 to 30, and take out 9 winners.  One will be Grand Prize, and two will be Second Prizes, and 6 Third Prizes.


const NUM_PARTICIPANTS = 30;
const NUM_DRAWN = 9;

console.log(`run: ${NUM_PARTICIPANTS} draw ${NUM_DRAWN}`);

const arr = Array.from(new Array(NUM_PARTICIPANTS), (e, i) =&gt; i + 1);

for (let i = arr.length - 1; i &gt;= NUM_PARTICIPANTS - NUM_DRAWN; --i) {
  const j = Math.floor(Math.random() * (i + 1));
  [arr[i], arr[j]] = [arr[j], arr[i]];
}

const results = arr.slice(NUM_PARTICIPANTS - NUM_DRAWN)

console.log(`Grand prize: ${results[0]}`);
console.log(`Second prize: ${results.slice(1,3).sort((a, b) =&gt; a - b).join(" ")}`);
console.log(`Other winners:\n  ${results.slice(3).sort((a, b) =&gt; a - b).join("\n  ")}`);
To pick m from a collection of size n, there are at least a couple of approaches:

Randomly shuffle an array of numbers (O(n)), then pick the top few (O(m)), resulting in an overall complexity of O(n + m). This is what you're doing currently, though it requires creating an array.

Repeatedly pick a random number from 0 to n. If a duplicate number is picked, pick again. Repeat m times. This has an overall complexity of around O(m * C), where C is the average number of picks before finding a non-duplicate. It'll range from close to 1 (when n is significantly larger than m) to infinity (when n is equal to m).


If m is close to n, the first approach, that you're doing currently, is quickest, because the other approach results in lots of collisions. If m is significantly smaller than n, the second approach is quickest, because the collision chance C is low. So, where's the break-even point? It'll depend on the engine running the code, but we can do some basic performance tests. I'll use Blindman67's code since it's pretty much exactly what I had in mind.
The below tests aren't 100% reliable, but they'll give you an OK idea of the time required:


// Array sort method:

// Wait for the iframe to load completely
setTimeout(() =&gt; {
  
  const NUM_PARTICIPANTS = 1e7;
  const NUM_DRAWN = 3e6;

  const t0 = performance.now();
  // Make the array 0-indexed rather than 1-indexed
  // to avoid unnecessary addition for every element
  const arr = Array.from({ length: NUM_PARTICIPANTS }, (_, i) =&gt; i);
  const lowerLimit = NUM_PARTICIPANTS - NUM_DRAWN;
  for (let i = arr.length - 1; i &gt;= lowerLimit; --i) {
    const j = Math.floor(Math.random() * (i + 1));
    // Use a temp variable instead of the destructuring trick, it's probably a bit faster:
    const temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
  }
  // Then slice the array and calculate the results
  console.log('Time taken:', performance.now() - t0);
}, 1000);





// Picking random numbers until enough non-duplicates are found:

// Wait for the iframe to load completely
setTimeout(() =&gt; {
  
  const NUM_PARTICIPANTS = 1e7;
  const NUM_DRAWN = 3e6;

  const randomInt = range =&gt; Math.floor(Math.random() * range);
  const results = createWinners();      

  function createWinners() {
       const t0 = performance.now();
       var draw = randomInt(NUM_PARTICIPANTS);
       const winners = new Set([draw]);  
       while (winners.size &lt; NUM_DRAWN) {
           do {
               draw = randomInt(NUM_PARTICIPANTS);
           } while (winners.has(draw));
           winners.add(draw);
       }
       // Then slice the array and calculate the results
       console.log('Time taken:', performance.now() - t0);
       return [...winners.values()];
  }
}, 1000);



I tried a few different combinations of NUM_PARTICIPANTS and NUM_DRAWN numbers, and the ratio used above, 10 to 3, seems to be pretty close to the break-even point, when the numbers are large, at least on V8. (If the code runs in a different environment, the ideal ratio to use will likely be different.) At that point, both methods look to take a pretty similar amount of runtime. If you don't have a large number of participants, either approach will work just fine, since it won't take any noticeable amount of time to run the script regardless. But if you have a large number, check the ratio of participants to winners, then choose the appropriate method depending on the ratio.
const createWinners = (numParticipants, numWinners) =&gt; (
  numWinners / numParticipants &lt; 0.3
    ? createWinnersWithSetPicking(numParticipants, numWinners)
    : createWinnersWithArraySort(numParticipants, numWinners)
);

If you change the code, you might want to run a performance test again to see what works better, and you might want to adjust the magic ratio. Take into consideration the environment the code runs (or is most likely to run) on too - Node? Chrome? Firefox? Safari?
That said, if your numbers are large enough that performance is an issue, this should almost certainly be done on the server (in Node), not on the client, so as not to be unrunnable on low-end devices.