var is not block-scoped. Since the for..-loop is sync, JS will first go through the loop three times, setting the timers, and then execute the timer handlers after the loop is finished. var i will be changed on a global scope. In the end, i will be 3 and once the timer handlers execute, that's what they print.
let is block-scoped, so on every loop execution, new memory will be allocated and bound to the loop block (current scope) for i. Once the timer handler executes, it will access the i of the loop invocation it was created in for the timer, so you will actually access different memory locations for every timer handler and have different results.
var and let are both valid options, depending on what you want to do. Most of the time, though, var is the un-intuitive option and not what you want, so I recommend defaulting to const, and use let for mutable variable bindings (like the for...-loop in your question).
Let me also recommend reading this Hashnode article, which describes the difference between var and let/const pretty well: