SURMA: Is my microphone on? It is. Woo! Your turn, Jake. Amuse me. JAKE: Well, no promises there. So Surma, I've been having a
look at other web tech YouTube videos and channels
and how they do things. And I've noticed that the
popular ones, what they do is they take tricky topics
and make them simple and easy to digest for people. Whereas what we do, or I
guess mostly what I do, is I take something simple and
make it really complicated. And I think that's
why we don't get the billions of views, Surma. SURMA: And so are you now
trying to rectify your path? JAKE: No, bad news. I'm going to do exactly that
this episode because we're going to make this. It's a counter. SURMA: OK. JAKE: Yeah. SURMA: That's some senior
engineering right there. JAKE: Absolutely. It's the kind of thing you'll
see in hundreds of tutorials. You'll see loads of
framework components that are doing
this kind of thing. And they're all wrong, or the
vast majority of them are. Or they're at least
inaccurate or sub-optimal. It tends to fall into one
of those or both of those. SURMA: When you say inaccurate,
do you mean not frame-perfect? JAKE: No, I mean
worse than that. I mean, like, it's
displaying the wrong number. SURMA: Oh, god. JAKE: And I'll show you
why and how sometimes the most obvious
way is going to lead to stuff like that happening. SURMA: Mm-hmm. Well, I'm at the
edge of my seat. JAKE: But there's something
I'm going to look at. It's not just going
to apply to timers. It's going to apply to all
kinds of low-frequency animation from blinking cursors to drawn
animation, that kind of thing. I'm going to cover how animation
scheduling works as well and some interesting browser
bugs because it turns out a lot is involved in making
a simple counter. But there you go, counter,
millions of views. SURMA: Knowing you, you
implemented this timer in your JavaScript-based
slide framework. Is it using the
correct technique? Or did you take a shortcut? JAKE: It's the correct
technique, absolutely. The correct technique
is actually quite small. It's not many lines of code. But there's a lot to it and a
lot of gotchas along the way. So I'm going to start
with a simple version. There you go. It's setInterval. And that's going to
call this callback. Well, we've told it, every 1,000
milliseconds or every second, as humans would call it. SURMA: I'm going to channel my
inner Paul Lewis, who basically the second you even start
using setInterval he will call your code bad. JAKE: Go on then. What's wrong? What have I done wrong? SURMA: Well, both setInterval
and setTimeouts, basically what they do is they
schedule something, put it into the event queue
after a period of time. But that doesn't mean
that it will actually run after the period of time. If I block the main thread for
two hours, this will never run. Seconds will not get increased. And so this could
drift any which way. And setInterval, specifically,
will schedule a task every second with this
callback function, regardless of whether the
previous scheduled task from the same interval
has already run. And that's often
not what you want. In this case, it might
actually be what you want. But in most cases, it's not. JAKE: Well, according
to the spec, it will call the callback. And then once the
callback is called, it will then schedule
the next one. But you-- SURMA: Oh, so it's literally
equivalent to, in essence, a timeout? JAKE: Oh, absolutely. Yeah. SURMA: Ah. JAKE: Certainly in the spec. You called it right. Drift is the problem here. Even in your best case,
Safari will drift about half a millisecond with every call. So everything starts out fine. But as we go on and on,
like 125 seconds later, we're now half a second late. And by the time we get to 256
seconds-- but at this point, we're now displaying
the wrong time. SURMA: Yes. Now we have crossed
the boundary. JAKE: And that is
best case in Firefox. It will take longer in
Safari in best case. But as you said,
if something blocks the main thread for
a bit, those drifts are going to be exaggerated. Also, if you put a
tab in the background, those drifts are massively
exaggerated as well. So things are going to get
inaccurate really fast. Interestingly,
Chrome doesn't drift. It actually auto-corrects, even
though that is not in the spec. You could say that's what
developers might expect. But-- SURMA: Does it make
a spec uncompliant, or is there leeway in
the spec to do this? JAKE: There isn't leeway
in the spec to do this. So it's non-compliant. SURMA: [GASPS] JAKE: Although, you
could argue it's-- I know, right? It might be more what
developers expect. But, yeah, it's not
complaint with the spec here. SURMA: Dun-dun-dun. JAKE: But again, if
it's in the background, it's going to be throttled. If there's a lot of
main thread work, it's going to be
delayed by some. And it could still
miss those seconds. So let's rate this solution. Not accurate over
time-- that's bad. But it does appear
to update steadily. So this goes to what you were
saying at the start, the frame accurate thing. I don't care so much about that. If it appears to
update every second, that's good enough for me. So it passes this one, even
though it's slightly out. To the human eye, it
seems good enough. It also runs code
in the background. It's a visual thing. So updating the dom when it's
not visible, that's bad, right? You're using the CPU when
you don't have to use CPU. But otherwise CPU
usage is pretty good. SURMA: Well, two out
of four, not too bad. JAKE: Not too bad. Let's see if we can
do better than that. Next solution-- so here I'm
taking the time from date.now. So that's going to be
accurate over time. And that's really
the difference here. But we still have that drift. And that is still a problem. So here's the drift again. I've exaggerated the
drift to make it easier to see in this diagram. But watch what happens. So now with the drift,
we've reached a point where we've crossed
that second boundary. So what the user is actually
going to see is 15, 16, 18. It won't happen in
Chrome because it doesn't have that drift. But this will happen
in Safari, in Firefox. SURMA: Well, this
is more than drift. This is more about it actually
is longer than exactly 1,000 milliseconds
until the task gets run. Because if it was exactly
1,000 milliseconds, the drift wouldn't
be that hurtfull. We would see 17 seconds for
a very short amount of time. But we would see it probably. JAKE: No, because the drift
is going to be consistent. You're only-- because it's
going to be, like, 1,000 milliseconds plus a bit, right? That's your drift. So that's what we see
here, is it displays 16, but in reality, it's mostly-- it's almost 17. So then the next
tick is actually 18. So 15, 16, 18, each
of those is going to appear like it lasts
a second to the user. But it's slightly longer,
which is why it ticks over to the next second. And like we saw in
Firefox, it's like you're 250 seconds in before you
would see this happen. But that's best case. If people are
moving between tabs or there's a lot of
main thread work, you are more likely
to see this as a user. And it looks weird. But let's say you add some
code to stop the timer running in the background,
because that's one of the things we wanted to fix. When that timer is
reactivated, there's a good chance it might
be towards the end of that second boundary anyway. So you're more likely
to see that skip happen in Firefox and Safari. But with Chrome's
drift correction, it is possible to end up with
this, where Chrome ends up correcting back and forth
around the boundary of a second. So the user is going
to see 21, 23, 25, each for around 2 seconds,
because it's back and forth on that second boundary. I mean, you'd have to be
pretty unlucky to [? land ?] with this. But if you've got
thousands of users and they're changing
tabs and whatever, some user is going to
report this timer is doing weird things. And you are not
going to know why. It's going to be really
hard to debug that. SURMA: Also, you
didn't store the return value of setInterval. So this code doesn't
even work, Jake. JAKE: Look, just
gloss over that. We will get to-- actually, I don't-- the actual
solution that I'm going to put in the description of this
video, it will be cancelable. But yes, so I glossed over that. I glossed over that. It's fine. So even if extra codes will stop
the timer in the background, we've introduced a new problem. The user is going to see skipped
numbers or uneven numbers or whatever. It's not good. Back to the drawing board. So this is pretty similar
to our previous example. But I'm going to use
requestAnimationFrame to do the scheduling. This might not feel like
an animation, but it is. It's something that
visually updates over time. SURMA: Yeah, this is kind of
shooting at birds with cannons or whatever the actual
turn of phrase is. JAKE: Yes. So this is good in some
ways because, of course, the animation frame pauses
when the page is hidden. So we're getting that
feature for free. But go on, what's the problem? SURMA: I guess about
984 milliseconds CPU usage are wasted because there's
nothing to update on the UI. JAKE: Yeah. SURMA: So, yeah,
59 frames, you have nothing to update
for the 1 frame that you have
something to update. JAKE: RequestAnimationFrame
pushes-- it runs as fast as
updates are pushed to the monitor, which is
usually, as you say, 60 times a second. So modern phones run at 90. 144 times a second is
popular on gaming monitors. SURMA: My iPad runs at 120. Oh, no, RequestAnimationFrame
doesn't. The screen is 120, but
Safari doesn't skip. That's another story
for another time. JAKE: But there are
VR screens and all of that sort of
stuff, which are going to be running at that kind
of rate, if not higher. We're running code 60 times more
than we need to is the point. And that shows up. That shows up in your task
manager or your activity monitor or whatever. It's bad. We've swapped one
problem for another. SURMA: Might even show
up in your battery drain. JAKE: It will absolutely show
up in your battery drain. And in some cases, it will
turn fans on in your machine. It's something you will notice. So the way I tried to solve
this, I tried to be smart. I felt-- SURMA: Ah, don't do that, Jake. We've tried this before. JAKE: Well, as
you'll see, I failed. So it's like all of the other
times I try and be smart. Look, animations felt
like the right thing, because it's the right
part of the event loop and all that stuff. So I thought, can
I make an animation that is every second rather
than 60 times a second? So I started with this. So this is a bit
like performance.now, in that it's the time since
the creation of the documents. But it's the time for
the current frame. Like, in a tight loop,
if you called date.now or performance.now, you'll
get different values, which is useful in some ways. Whereas current
time is always going to give you the same number
within the same frame. And this is how animation
scheduling works on the web. Because if you start
multiple animations in the same task or
part of the same frame, they will all start in sync
because they get their start time from the current time. SURMA: That seems very sensible. JAKE: Absolutely. If you've used
RequestAnimationFrame before, you'll notice it actually passes
you in a time to your callback. It's the same thing. It's the current
time of the frame. So I've got my frame
callback, as before, calculate which second we're
in, as before, just using current time instead. But to schedule a frame,
going to use the web animation API with no keyframes. I'm using the body element just
because any element will do. No keyframes, so I'm using null. And it's got a duration of
1 second minus how far we're currently through the second. So that's what we're using
the modulus for there. And that's our drift correction. SURMA: Ah. OK. JAKE: A bit like what Chrome
does with setInterval. And then we call frame
again, when the animation is done, using the frame events,
[INAUDIBLE] the finish event. So using the
on-frame thing that-- SURMA: I thought you were
going to go with a step function or something. But this works. OK. JAKE: Yeah. So I felt very clever. Doesn't work, though, does it? The CPU usage is about the
same as RequestAnimationFrame. And that's in Firefox,
Safari, and in Chrome. It turns out that browsers are
using something very similar to RequestAnimationFrame
internally to queue this up. Thanks, browsers. I really thought I was
onto something here. SURMA: Yeah, I'm
actually surprised, because I figured at very
least, your code like the code that you wrote would at
least be less frequent. But I guess if the browser
is under the hood still doing a RAF to check
if the unfinished needs to get invoked, then you're not
saving much, maybe a tiny bit, but, yeah, very interesting. JAKE: Saving a little
bit on the dom update, but, yeah, it uses
about the same CPU. Yeah, so not great. You can actually do this
without JavaScript at all. Here is a two-digit timer
with [INAUDIBLE] CSS. But it has the same problem. Chrome, Safari, Firefox, all
have high CPU usage here. Also, it doesn't work in Safari. You just get the CPU usage. It doesn't actually
change the display. They are actually
working on that. Fair enough. SURMA: I'm surprised you went
with linear and not a step function. I feel like that's
what it was made-- I mean, content probably
just flips over at 50%. But with step function
it would have probably been a bit more intuitive. JAKE: Yes. So with what they call a
discrete value animation, it flips over at
50%, which is why I've used an animation
delay here to sort of correct for that 50% switch. And you're right. I could have used step. When you use steps, which
is an easing function, you can also specify is it
in the middle, at the start, or at the end. Yeah. So I could have used that. I was trying not
to complicate it. But thanks, Surma. We've complicated it. I don't know why I didn't
try and complicate it because this whole
episode is basically just an over complication
of something really simple. Fine. It's fine. But yes, this uses way more CPU
than our setInterval version, which surprises me
because I usually have this thing in my head
that if you can do something without JavaScript and
it's not a total hack, then that's probably
the best way to do it. But that's not the case here. SURMA: I guess that
makes it a hack then. JAKE: For now, yeah, unless
browsers can optimize it. So what now? We need to go back to
plain jobs four timeres. But let's include
some of the stuff that we learned from animations. As before, we're going
to use the current time. It's in animation time. This is an animation. We're also going to
pass that into our frame function for the first call. This bit, same as before, figure
out the second, display it. But here's how we're going
to schedule the next frame. We're going to setTimeout,
which is a bit like setInterval, but it only happens once. When that calls
back, we're going to use RequestAnimationFrame
as well. And that gives us the-- well, it synchronizes
with other animations, but it's also going to
stop it running when the page is in the background. And it's also going to provide
us the time for the next frame as part of our callback. Great. For the delay we're going to do
similar to what we did before, wait a second, but reduce
that by the amount of second that's already passed. But we're using
setTimeout this time. This is good in Firefox and
Safari but not in Chrome. SURMA: Really? JAKE: I don't know if it's a bug
or if it's expected behavior. This is what we want,
something like this, one frame per second. But due to
synchronization issues between setTimeout and
the current frame time, it undershoots a bit. It actually happens before
the second boundary. So our drift correction
schedules another callback almost immediately. So you get this pattern like,
boom, boom-boom, boom-boom, boom-boom. It looks fine to the
user because we're using not date.now. SURMA: Interesting. So it actually-- yeah, because
it schedules too early, the actual second you
were trying to complete hasn't quite completed yet,
and you schedule another one immediately almost. JAKE: Yes, exactly that. It's doing-- SURMA: So this is a
case for the double RAF? JAKE: Doesn't help. Or you end-- SURMA: Oh! JAKE: Maybe double
RAF would help, but then you're pushing
things out by a frame. And I did experiment
with just putting an extra delay of, like,
some milliseconds onto this. But it's not always accurate. Yeah. So it felt like a bit of a
hack to do something like that. It's not the end of the world. We're running code twice
as much as we need to. And that's only in Chrome. SURMA: Saying this
is not good CPU usage is also technically correct,
but also kind of unfair compared to how wasteful
the others solutions were. JAKE: Yeah. We were 60 times the
amount of work before. We're now twice
the amount of work. And that's a big difference. But this is actually
two or three. So we're going to
fix this properly. SURMA: Yeah, you better. JAKE: When you've got
an inaccuracy, that can happen in both directions,
positive and negative. The solution is to
round rather than floor. There's actually three bits
of flooring code in here. Can you spot them? SURMA: Well, there's the floor. That definitely floors. JAKE: Yes, I was hoping
you would get that one. SURMA: I'm guessing current
time is also implicitly rounded or floored at the start, maybe? JAKE: It's actually
not, although that can depend on the browser. And that comes from
performance.now is rounded in some browsers
and not in some others. And that can also depend
on high-resolution timers and stuff, anyway. SURMA: So I guess the set
timer parameter is also implicitly rounded to
milliseconds, at the very least? JAKE: Yeah, well, done actually. I'm impressed you got
any more of these. You're right. SetTimeout, what it will do is
floor whatever you give to it. And that's as per the spec. So if you pass it, like, 99.99,
it's going to come down to 99 rather than go to 100. SURMA: And the only
last one I can think of would be the
parameter frame, which is given to your by
RequestAnimationFrame. JAKE: No, the other one
is the modulus operator, which is a flooring-like
operator because it's giving you the amount that
you've overshot the second. It's not giving you the
distance to the next second. So this could give you 999 when
it's actually 1 millisecond till the next second. So it's not strictly
flooring, but it's going-- it's always counting back. SURMA: Right. Oh, fine. I'll give you that one,
flooring and the like. JAKE: Yeah. But it's one of
those things that we would look at, at some point. And, yes, the setTimeout
is the other one. So let's get to work. I'm going to get rid of that
scheduler we had before. And I'm going to swap
that floor for a round. There we go. SURMA: So you're only
using the-- well, if we're you're trying just
a floor into a round, that is only used for the seconds
variable, which we only use to update the UI. So it doesn't really affect
how we schedule things. JAKE: We're going to use
it to schedule things. Here we go. SURMA: Oh! JAKE: We're going to figure out
what time the next frame should be. So that's the second
that we are displaying, which we've rounded plus
1 turned into milliseconds and add it to the start time. So that's when we want
the next frame to happen. And then similar
to before, we're using setTimeout,
RequestAnimationFrame. But the target time, it's
going to take our delay minus whatever time it is now. SURMA: And that's
a big difference because now we're using
performance.now, which takes the current
time, like, literally, when is this line of
JavaScript executed not when was the last frame shipped,
which is what current time is. JAKE: Yes. And that's because that's
what setTimeout uses. When you give it 100, it's
100 from the time it's called not the time of the last frame. So, yes, it makes sense to
use performance.now here. We could round this as well. But it's not necessary. Rounding the seconds is enough. And that's it. That's how you do
a simple timer that achieves all of these things. SURMA: And we have no
double frame anymore. This is literally you ship a
frame exactly when you need to on the second. JAKE: Yes. SURMA: Incredible. JAKE: And I will include
a link to a version in the description
that can schedule for intervals over the seconds. So if you wanted to do
something every 12 times a second, which some animations,
drawn animations like that, you can have that, and
it's cancelable with an abort controller as well. But I'm not done yet. I know this is a long episode. But I'm not done yet. Something was keeping
me up at night. and it's like, this should
have been the answer. This should have worked,
all the web animation API. It really bugged me that this
performed badly in browsers. So what I did is I
wondered are there other kinds of animation that
are badly optimized like this. So I went and
started testing them, other kinds of web animation
that have infrequent changes. So we've seen empty
web animations. You can also do this with CSS. Not sure why you would
want to, but you can. It performs badly
in all browsers. They run internal code
every frame, 60 times a second or whatever. Animations can
have a delay where nothing happens for a bit. So this is a
five-second animation, but 10 seconds happened--
nothing happens before the animation kicks in. Chrome and Safari
optimize for this. But Firefox doesn't. And that's a shame
because that could have been my workaround for
the web animation thing. I could have used a delay in
a zero-duration animation. Anyway, never mind. Here's a 10-second animation. But you mentioned steps before. It's only going to
have two frames. Safari optimizers for this,
but Firefox and Chrome do not. SURMA: Oh, that's interesting. JAKE: So well
done, Safari, here. And then finally this one. So here we've got something. It fades out a bit and
then fades back in. And then it waits. So most of the animation is
just the thing sitting there, opacity 1. I actually use this on the
Chrome [? decimate ?] website. Like, when the
conference was live, I had a little red
circle, like a record. SURMA: The record light, yeah. JAKE: And every now and
then it would sort of pulse, using this. No browser optimizes for it. It's-- SURMA: That's Interesting. JAKE: It's kind of sad. SURMA: In these
examples, at least, it seems fairly easy
to do this analysis, whether there's basically
dead time in the animation. But I'm guessing it will become
increasingly hard when you have more complex animations. JAKE: Yeah. And fair play to Safari
for optimizing more cases than Firefox and Chrome. But there are still real-world
improvements that can be made. So I'll add them
to the description, because I filed bugs with all
of the browsers about this. So you can track when they
start fixing these things. I think the empty
animation, which is the one that was important
to me for this, the timer, I think they're going
to fix that first. It'll probably be Safari that
fixes it first, which is great. But, yeah, you can
follow along for that. And finally, that is all I had. That's all I've got. And so hopefully
this episode is going to get the millions of
views because everyone's desperate to watch a-- I don't even know how
long it lasted-- like, a 45-minute episode on
creating a JavaScript counter. SURMA: Just-- yeah, just count
up, but do it efficiently. JAKE: [CLEARS THROAT]
Oh, yes, good. That's good coffee. Mm. [CLEARS THROAT] (DEEP
SCRATCHY VOICE) Oh, yes. Oh, I'll just do the rest of
the episode in this voice. Ooh. Sexy. Sexy, sexy JavaScript.